NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Show Price as Hours of your Life
// @description Gets the price displayed on shopping sites and shows the equivalent hours of your life spent to earn that money. Also alters dark patterns!
// @author navchandar
// @version 3.2
// @license MIT
// @run-at document-end
// @homepage https://navchandar.github.io/
// @homepage https://github.com/navchandar/
// @homepageURL https://navchandar.github.io/
// @copyright 2019, navchandar (https://openuserjs.org/users/navchandar)
// @icon data:image/png;base64,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
// @include https://play.google.com/store/*
// @include https://play.google.com/store*
// @include http://play.google.com/store*
// @match https://www.google.com/*shop*
// @match https://store.google.com/*
// @match https://google.com/*shop*
// @match https://www.google.co.in/*shop*
// @match https://google.co.in/*shop*
// @include http://www.amazon.com
// @include https://www.amazon.com
// @include http://www.amazon.in
// @match https://www.amazon.in/*
// @include https://www.flipkart.com*
// @include http://www.flipkart.com*
// @include https://paytm.com*
// @include http://paytm.com*
// @include https://paytmmall.com*
// @include http://paytmmall.com*
// @match *://*.amazon.in/*
// @match *://*.amazon.com/*
// @match *://*.flipkart.com/*
// @match *://*.paytm.com/*
// @match *://*.myntra.com/*
// @match *://*.steampowered.com/*
// @match *://play.google.com/*
// @match *://*.google.com/store/*
// @match *://*.google.co.in/shopping/*
// @match *://*.google.*/search*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @namespace https://greasyfork.org/users/302262
// @supportURL https://openuserjs.org/scripts/navchandar/Show_Price_as_Hours_of_your_Life/issues
// @setupURL https://openuserjs.org/install/navchandar/Show_Price_as_Hours_of_your_Life.user.js
// @updateURL. https://openuserjs.org/meta/navchandar/Show_Price_as_Hours_of_your_Life.meta.js
// @downloadURL https://openuserjs.org/install/navchandar/Show_Price_as_Hours_of_your_Life.user.js
// ==/UserScript==
(function () {
"use strict";
// ═══════════════════════════════════════════════════════════
// 1. SITE CONFIGURATIONS
// ═══════════════════════════════════════════════════════════
/**
* Each entry describes ONE website
*
* @typedef {Object} SiteConfig
* @property {string} name – human-readable label (for logs)
* @property {function} test – receives `window.location.href`, returns boolean
* @property {string} xpath – XPath that selects price-containing elements
* @property {string} separator – HTML inserted before the appended label
* @property {string} currency – currency symbol used to strip text (₹ / $ / etc.)
* @property {array} darkPatterns – dark patterns to apply custom styles (EMI price should not look larger than the actual price)
*/
const SITE_CONFIGS = [{
name: "Amazon India",
test: (url) => url.includes("amazon.in"),
currency: "₹",
separator: " ",
// ── Locators for price / amount displayed ───────────────────
xpath: [
"//span[contains(@class, 'CurrencyINR')]//following-sibling::span",
"//span[@class='currencyINR']/parent::*",
"//*[contains(@class, 'rice')][contains(text(), '₹')]",
"//*[contains(@class, 'price')]//*[contains(text(), '₹')]",
"//*[contains(@class, 'price-whole')]",
"//h2/span[contains(text(), '₹')]",
"//div[@data-id]//h3",
"//*[text() = 'Select Seats']/../..//*[contains(text(), ',')]",
"//*[@id='APayBalance']//span[contains(@class, 'currency')]",
"//div[contains(@class, 'secondary')]/span[contains(@class, 'base')]",
].join(" | "),
// ── Dark pattern neutralisers ───────────────────
darkPatterns: [{
// Giant EMI amount shown to make price look small
xpath: "//span[contains(@class, 'emi-')]",
styles: {
fontSize: "11px",
fontWeight: "normal"
},
},
{
// Savings Percent size can be smaller
xpath: "//span[contains(@class, 'ercent')]",
styles: {
fontSize: "12px",
fontWeight: "normal"
},
}
]
},
{
name: "Amazon US",
test: (url) => url.includes("amazon.com"),
currency: "$",
separator: " ",
xpath: [
"//*[@class='dealPrice']",
"//*[@class='buyingPrice']",
"//*[contains(@class,'-price-whole')]",
"(//span[@class='dv-conditional-linebreak']//following-sibling::text())[1]",
"(//span[@class='dv-conditional-linebreak']//following-sibling::text())[2]",
"//span[contains(@class, 'price')][contains(text(), '$')]",
"//h2[contains(@class, 'headline') and contains(text(), '₹')]",
"//a[@id='dealTitle']",
"//div[contains(@class, 'priceBlock')]",
"//a//span/span[@class and contains(@class, 'price') and not(contains(@class, 'decimal')) and not(contains(@class, 'fraction'))]",
"//div[contains(@class, 'price')]/span[contains(@class, 'price') and contains(@class, 'Grid')]",
].join(" | "),
darkPatterns: [{
xpath: "//span[contains(@class, 'price-decimal') or contains(@class, 'price-fraction')]",
styles: {
fontSize: "10px"
},
},
{
// Savings Percent size can be smaller
xpath: "//span[contains(@class, 'ercent')]",
styles: {
fontSize: "12px",
fontWeight: "normal"
},
}
]
},
{
name: "Flipkart",
test: (url) => url.includes("flipkart.com"),
currency: "₹",
separator: " ",
xpath: [
"//*[contains(@title, '₹')]/div[3]",
"//div[contains(text(), '₹')]",
].join(" | "),
darkPatterns: [{
xpath: "//div[contains(@style, 'text-decoration-line: line-through') or contains(text(), '%')]",
styles: {
fontSize: "12px"
},
},
{
xpath: "//*[contains(text(), 'Upto')]//..//..",
styles: {
fontSize: "10px"
},
},
{
xpath: "//div[contains(text(), 'EMI')]//..//..//..//..",
styles: {
fontSize: "10px"
},
},
{
xpath: "//*[contains(text(), 'EMI')]//ancestor::*[contains(@class, 'grid')]",
styles: {
fontSize: "10px",
color: "white",
background: "lightgrey"
},
}
]
},
{
name: "Steam",
test: (url) => url.includes("steampowered"),
currency: "₹",
separator: " ",
xpath: [
"//div[contains(@class, 'final_price')]",
].join(" | "),
darkPatterns: [{
xpath: "//div[contains(@class, 'discount_pct') or contains(text(), 'discount_percent')]",
styles: {
fontSize: "10px"
},
}, ]
},
{
name: "Paytm",
test: (url) => url.includes("paytm"),
currency: "₹",
separator: "<br/>",
xpath: [
"//span/span[contains(text(), 'Rs')]",
"//a//*[contains(@class, 'iconRupess')]/..",
"//*[contains(@class, 'iconRupess')]/..",
].join(" | "),
},
{
name: "Google Play Store",
test: (url) => url.includes("google") && url.includes("details"),
currency: "₹",
separator: " ",
xpath: [
"//*[@class='display-price' and contains(text(), '₹')]",
"//span[contains(text(), '₹')]",
"//button[@jsmodel]//span[contains(text(), '₹')]",
"//button[contains(text(), '₹')]//text()",
"//meta[@itemprop='price']",
].join(" | "),
},
{
name: "Google Store",
test: (url) => url.includes("google") && url.includes("store"),
currency: "₹",
separator: "<br/>",
xpath: [
"//*[@class='display-price' and contains(text(), '₹')]",
"//button[@jsmodel]//span[contains(text(), '₹')]",
"//button[contains(text(), '₹')]//text()",
"//meta[@itemprop='price']",
].join(" | "),
darkPatterns: [{
xpath: "//*[@data-component-name='pricing_price_base']//*",
styles: {
fontSize: "10px",
fontWeight: "normal"
}
},
{
xpath: "//*[@data-component-name='rich_text']",
styles: {
fontSize: "12px",
fontWeight: "normal"
}
}
],
},
{
name: "Google Shopping",
test: (url) => {
try {
// Method 1: new-style Shopping tab (udm=28 param)
const u = new URL(url);
if (u.hostname.includes("google") &&
u.searchParams.get("udm") === "28") {
return true;
}
}
catch {}
// Method 2: (google.co.in/shopping, etc.)
return url.includes("google.co") && url.includes("shop");
},
currency: "₹",
separator: " ",
xpath: [
"//div[contains(@class, 'content')]//*[contains(text(), '₹')]",
"//div[contains(@class, 'result')]//*[contains(text(), '₹')]",
"//a//div[contains(text(), '₹')]",
"//span[contains(@aria-label, 'Current')]",
].join(" | "),
},
{
name: "Myntra",
test: (url) => url.includes("myntra.com"),
currency: "₹",
separator: " ",
xpath: [
"//*[contains(@class, 'discountedPrice')]",
"//*[contains(@class, 'pdp-price') or contains(@class, 'pdp-offers-price')]",
"//*[contains(@class, 'product-item-selling-price') or contains(@class, 'product-price')]"
].join(" | "),
darkPatterns: [{
xpath: "//*[contains(@class, 'discountPercent') or contains(@class, 'product-strike')]",
styles: {
fontSize: "10px"
},
},
{
xpath: "//*[contains(@class, 'pdp-discount') or contains(@class, 'mrp')]",
styles: {
fontSize: "10px"
},
},
]
},
];
// ═══════════════════════════════════════════════════════════
// 2. CONSTANTS
// ═══════════════════════════════════════════════════════════
/** Working-hours calendar */
const WORK = Object.freeze({
HOURS_PER_DAY: 9,
DAYS_PER_MONTH: 22,
MONTHS_PER_YEAR: 12,
// Add calculated property here
get HOURS_PER_MONTH() {
return this.HOURS_PER_DAY * this.DAYS_PER_MONTH; // 198
},
get HOURS_PER_YEAR() {
return this.HOURS_PER_MONTH * this.MONTHS_PER_YEAR; // 2376
}
});
/** Thresholds (in hours) that decide which unit to display */
const THRESHOLDS = Object.freeze({
HOURS_MAX: 10, // ≤ 10 h → show hours
DAYS_MAX: WORK.HOURS_PER_MONTH, // ≤ 198 h → show days
MONTHS_MAX: WORK.HOURS_PER_YEAR, // ≤ 2376 h → show months
// above MONTHS_MAX → show years
});
/** Markers injected into the DOM so we never process an element twice */
const MARKER_ATTR = "data-plh-processed";
const MARKER_WORDS = ["hrs", "Day", "Month", "YEARS"];
/** Console prefix for easy filtering in DevTools */
const LOG_PREFIX = "[PriceLifeHours]";
/** Polling interval (ms) – used as fallback when MutationObserver fires too infrequently */
const POLL_INTERVAL_MS = 3000;
// ═══════════════════════════════════════════════════════════
// 3. UTILITIES
// ═══════════════════════════════════════════════════════════
/** Prefixed console helpers */
const log = (...args) => console.log(LOG_PREFIX, ...args);
const warn = (...args) => console.warn(LOG_PREFIX, ...args);
const error = (...args) => console.error(LOG_PREFIX, ...args);
/**
* Safe `String.includes` wrapper – returns false for null/undefined.
* @param {string|null|undefined} str
* @param {string} search
* @returns {boolean}
*/
function safeIncludes(str, search) {
try {
return typeof str === "string" && str.includes(search);
}
catch {
return false;
}
}
/**
* Check whether text already contains one of our injected markers.
* @param {string} text
* @returns {boolean}
*/
function hasMarker(text) {
return MARKER_WORDS.some((m) => safeIncludes(text, m));
}
// ═══════════════════════════════════════════════════════════
// 4. SALARY MANAGEMENT
// ═══════════════════════════════════════════════════════════
/**
* Prompt the user for their annual salary and persist it.
* @param {boolean} [force=false] – if true, always prompt even if a value exists
* @returns {number|null} the stored salary, or null if the user cancelled
*/
function promptAndStoreSalary(force = false) {
if (!force) {
const existing = GM_getValue("approxAnnualSalary", "");
if (existing) {
return Number(existing);
}
// User previously cancelled – don't nag on every page load
if (GM_getValue("UserCancelledPrompt", false)) {
log("Skipping salary prompt — user cancelled previously.");
return null;
}
}
const input = prompt(
"To calculate how many hours of your life each item costs,\n" +
"enter your approximate Annual Salary / Wage:",
""
);
if (input && Number(input) > 0) {
GM_setValue("approxAnnualSalary", input);
GM_setValue("UserCancelledPrompt", false);
log(`Stored annual salary: ${input}`);
return Number(input);
}
GM_setValue("UserCancelledPrompt", true);
warn("User cancelled salary prompt.");
return null;
}
/**
* Derive hourly rate from the stored annual salary.
* @returns {number|null}
*/
function getHourlyRate() {
const annual = Number(GM_getValue("approxAnnualSalary", ""));
if (!annual || annual <= 0) {
warn("No valid annual salary stored — cannot compute hourly rate.");
return null;
}
const hourly = annual / WORK.HOURS_PER_YEAR;
log(`Hourly rate: ${hourly.toFixed(2)} (annual: ${annual})`);
return hourly;
}
// ═══════════════════════════════════════════════════════════
// 5. SITE MATCHER
// ═══════════════════════════════════════════════════════════
/**
* Return the first SiteConfig whose `test()` matches the current URL.
* @returns {SiteConfig|null}
*/
function detectSite() {
const url = window.location.href;
for (const cfg of SITE_CONFIGS) {
try {
if (cfg.test(url)) {
log(`Matched site: ${cfg.name}`);
return cfg;
}
}
catch (err) {
error(`Error testing site config "${cfg.name}":`, err);
}
}
warn("Current URL did not match any configured site.");
return null;
}
// ═══════════════════════════════════════════════════════════
// 6. PRICE PARSER
// ═══════════════════════════════════════════════════════════
/**
* Extract a numeric price from a messy text string.
*
* Handles common noise: "+10% offers", "100-200" ranges,
* currency symbols, commas, "Used/New" suffixes, etc.
*
* @param {string} raw – the raw innerText / textContent
* @returns {number} – parsed price, or 0 if unparseable
*/
function parsePrice(raw) {
if (!raw) {
return 0;
}
try {
// ── Strategy 1: extract first price-shaped token ───────
// Covers: ₹8,499.00 | $12.99 | Rs.1,200 | Rs 500
const priceRegex = /(?:[₹$]|Rs\.?\s?)[\s]?([\d,]+(?:\.\d{1,2})?)/;
const match = raw.match(priceRegex);
if (match) {
const cleaned = match[1].replace(/,/g, "").replace(/\.00$/, "");
const amount = Number(cleaned);
if (amount > 0) {
return amount;
}
}
// ── Strategy 2: aggressive strip ─────
let txt = raw;
// Strip "+X% offer" suffixes (Flipkart)
if (txt.includes("+")) txt = txt.split("+")[0];
// Strip "–X%" discount prefixes (Paytm)
if (txt.includes("%") && txt.includes("-")) txt = txt.split("-")[0];
// Price ranges — take the higher end
if (txt.includes("-")) txt = txt.split("-").pop();
if (txt.toLowerCase().includes("to")) txt = txt.split(/to/i).pop();
// Strip trailing CTA text
if (txt.toLowerCase().includes("buy")) txt = txt.split(/buy/i)[0];
// Remove "X Used" / "X New" / "(XX%)" noise
txt = txt
.replace(/\d+\s*Used/gi, "")
.replace(/\d+\s*New/gi, "")
.replace(/\(\d+%\)/g, "");
// Remove descriptive suffixes BEFORE stripping letters
// "with 20 percent savings", "for 6 months", etc.
txt = txt
.replace(/with\s+\d+\s+percent.*/gi, "")
.replace(/per\s+month.*/gi, "")
.replace(/\/month.*/gi, "")
.replace(/for\s+\d+\s+months?.*/gi, "")
.replace(/\d+\s*%\s*off.*/gi, "");
// Now strip letters, symbols, whitespace
txt = txt
.replace(/[a-z]/gi, "")
.replace(/[₹$,\s]/g, "")
.replace(/Rs\.?/g, "")
.replace(/\.00$/, "")
.trim();
let amount = Number(txt);
// Last resort: digits only
if (!amount || amount <= 0) {
const digitsOnly = txt.replace(/\.\d+/g, "").replace(/\D+/g, "");
amount = Number(digitsOnly);
}
return amount > 0 ? amount : 0;
}
catch (err) {
warn("Price parsing failed for text:", raw, err);
return 0;
}
}
// ═══════════════════════════════════════════════════════════
// 7. DOM UPDATER (core logic)
// ═══════════════════════════════════════════════════════════
/**
* Format a "work-time" label for the given number of hours.
* @param {number} hours
* @returns {string} e.g. "3.2 hrs", "1.5 Days", "2.0 Months", "0.8 YEARS"
*/
function formatWorkTime(hours) {
if (hours <= THRESHOLDS.HOURS_MAX) {
return `${hours.toFixed(1)} hrs`;
}
if (hours < THRESHOLDS.DAYS_MAX) {
return `${(hours / WORK.HOURS_PER_DAY).toFixed(1)} Days`;
}
if (hours < THRESHOLDS.MONTHS_MAX) {
return `${(hours / WORK.HOURS_PER_MONTH).toFixed(1)} Months`;
}
return `${(hours / WORK.HOURS_PER_YEAR).toFixed(1)} YEARS`;
}
/**
* Find and de-emphasise dark-pattern elements (giant EMI prices, etc.)
* by forcing smaller, quieter styles on them.
*
* @param {SiteConfig} siteConfig
* @returns {number} count of elements restyled this pass
*/
function neutraliseDarkPatterns(siteConfig) {
if (!siteConfig.darkPatterns || siteConfig.darkPatterns.length === 0) {
return 0;
}
const MARKER = "data-plh-dp-fixed";
let fixed = 0;
for (const pattern of siteConfig.darkPatterns) {
try {
const nodes = document.evaluate(
pattern.xpath,
document,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
for (let i = 0; i < nodes.snapshotLength; i++) {
const el = nodes.snapshotItem(i);
if (typeof el.getAttribute !== 'function') {
// simple text node
continue
}
if (el.getAttribute(MARKER)) {
// already handled
continue;
}
// Apply every style override
for (const [prop, value] of Object.entries(pattern.styles)) {
// fontSize → font-size
const tmp = prop.replace(/([A-Z])/g, "-$1").toLowerCase();
el.style.setProperty(tmp, value, "important");
}
el.setAttribute(MARKER, "1");
fixed++;
}
}
catch (err) {
warn(`Dark-pattern XPath failed: "${pattern.xpath}"`, err);
}
}
if (fixed > 0) {
log(`Neutralised ${fixed} dark-pattern element(s).`);
}
return fixed;
}
/**
* Scan the page for price elements and append work-time labels.
*
* @param {SiteConfig} siteConfig
* @param {number} hourlyRate
* @returns {number} count of elements updated this pass
*/
function updatePriceElements(siteConfig, hourlyRate) {
let items;
try {
items = document.evaluate(
siteConfig.xpath,
document,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
}
catch (err) {
error(`XPath evaluation failed for "${siteConfig.name}":`, err);
return 0;
}
if (!items || items.snapshotLength === 0) {
// Not an error — the page may simply have no prices visible yet
return 0;
}
let updated = 0;
for (let i = 0; i < items.snapshotLength; i++) {
const node = items.snapshotItem(i);
try {
// Skip if we already processed this node
if (node.getAttribute && node.getAttribute(MARKER_ATTR)) continue;
const text = node.innerText || node.textContent || "";
const html = node.innerHTML || "";
// Skip if a previous run already injected a label
if (hasMarker(text) || hasMarker(html)) {
continue;
}
const price = parsePrice(text);
if (price <= 0) {
continue;
}
const hoursNeeded = price / hourlyRate;
if (!hoursNeeded || !isFinite(hoursNeeded)) {
warn(`Could not compute work-time for price ${price}`);
continue;
}
const label = formatWorkTime(hoursNeeded);
node.innerHTML += `${siteConfig.separator}<b>(${label})</b>`;
// Mark so we don't reprocess
if (node.setAttribute) {
node.setAttribute(MARKER_ATTR, "1");
}
updated++;
}
catch (err) {
warn(`Failed to process price node #${i}:`, err);
}
}
if (updated > 0) {
log(`Labelled ${updated} price element(s).`);
}
return updated;
}
// ═══════════════════════════════════════════════════════════
// 8. UI – FLOATING RESET BUTTON
// ═══════════════════════════════════════════════════════════
/**
* Inject a small fixed-position "Reset" button in the bottom-left
* corner so the user can update their stored salary at any time.
*/
function injectResetButton() {
try {
const container = document.createElement("div");
container.id = "plh-container";
container.innerHTML = '<button id="plh-reset" title="Click to update your stored salary" type="button">Reset Income</button>';
document.body.appendChild(container);
document.getElementById("plh-reset").addEventListener("click", () => {
log("User clicked Reset — prompting for new salary…");
const newSalary = promptAndStoreSalary( /* force */ true);
if (newSalary) {
log("Salary updated — reloading page to re-calculate.");
location.reload();
}
});
GM_addStyle(`
#plh-container {
position: fixed;
bottom: 0;
left: 0;
z-index: 99999;
font-size: 11px;
margin: 0;
opacity: 0.5;
transition: opacity 0.3s;
}
#plh-container:hover { opacity: 0.85; }
#plh-reset {
cursor: pointer;
background: #d3d3d3ad;
color: #333;
border: 1px solid #ccc;
border-radius: 3px;
padding: 3px 8px;
font-size: 9px;
}
#plh-reset:hover {
background: #555;
color: #fff;
box-shadow: 0 2px 6px rgba(0,0,0,.24), 0 5px 10px rgba(0,0,0,.19);
}
`);
log("Reset button injected.");
}
catch (err) {
error("Error during injecting button:", err);
}
}
// ═══════════════════════════════════════════════════════════
// 9. BOOTSTRAP
// ═══════════════════════════════════════════════════════════
/**
* Main entry point — runs once on page load.
*/
function init() {
log("Initialising…");
// ── Detect site ──────────────────────────────────────────
const site = detectSite();
if (!site) {
warn("Show Price as Hours: No Site match!");
return;
// nothing to do on unsupported sites
}
// ── Ensure we have a salary ──────────────────────────────
const salary = promptAndStoreSalary();
if (!salary) {
warn("No salary available — script will remain idle.");
injectResetButton(); // still show button so user can enter salary later
return;
}
const hourlyRate = getHourlyRate();
if (!hourlyRate) {
return;
}
// ── Inject UI ────────────────────────────────────────────
injectResetButton();
// ── First pass ───────────────────────────────────────────
updatePriceElements(site, hourlyRate);
neutraliseDarkPatterns(site);
// ── Observe DOM mutations (SPA / lazy-loaded content) ────
let debounceTimer = null;
const observer = new MutationObserver(() => {
// Debounce rapid-fire mutations to avoid excessive work
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
updatePriceElements(site, hourlyRate);
neutraliseDarkPatterns(site);
}, 500);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
log("MutationObserver attached — watching for new price elements.");
// ── Fallback polling for sites that swap the DOM silently ─
setInterval(() => {
updatePriceElements(site, hourlyRate);
}, POLL_INTERVAL_MS);
log(`Fallback poll running every ${POLL_INTERVAL_MS / 1000}s.`);
}
// ── Run! ──
try {
init();
}
catch (err) {
error("Fatal error during initialisation:", err);
}
})();