NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Rewardpoints per Dollar
// @namespace https://openuserjs.org/users/noahzark
// @version 3.3
// @description calculates the value of Marriott Bonvoy points when redeeming to free nights, gold means points are a great deal, red means below their going value, green means pay cash instead
// @author Feliciano Long
// @match *://www.marriott.com/search/*
// @match *://www.marriott.com/reservation/*
// @match *://www.marriott.com.cn/search/*
// @match *://www.marriott.com.cn/reservation/*
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect open.er-api.com
// @connect cdn.jsdelivr.net
// @connect api.exchangerate-api.com
// @license MIT
// ==/UserScript==
(function () {
'use strict';
var CONFIG = {
// US cents per point, as the two cut-offs between the three badge colours.
// Published 2026 baselines run 0.77-0.90 (FrequentMiler/Gondola median 0.77
// over ~3M redemptions, TPG 0.80, WalletHub 0.79), but mainland China rarely
// clears them — cheap cash rates against global award pricing put Shanghai
// around 0.50. Calibrated for booking inside China; raise both toward the
// published baselines when shopping US/Europe.
gold: 0.65, // at or above this, redeeming is a good deal here
green: 0.45, // at or below this, pay cash and keep the points
fxTtlHours: 12,
debug: false
};
// Gold only reads as gold when it is a filled chip: as text on white it dulls
// to olive well before it gets dark enough to be legible.
var TIER_STYLE = {
gold: 'background:#FFD700;color:#3D2E00;padding:1px 6px;border-radius:3px;',
red: 'color:#B3261E;',
green: 'color:#1A7F37;'
};
// Fallback only: used before live rates arrive, or if every endpoint fails.
// Units of local currency per 1 USD, snapshot 2026-08-03.
var STATIC_RATES = {
USD: 1, CNY: 6.76, HKD: 7.842, MOP: 8.078, TWD: 32.3, JPY: 158, KRW: 1441,
EUR: 0.867, GBP: 0.742, CHF: 0.807, SGD: 1.282, AUD: 1.42, NZD: 1.697,
CAD: 1.401, THB: 33.4, MYR: 4.087, IDR: 18076, PHP: 61.2, VND: 26180,
INR: 95.5, AED: 3.672, SAR: 3.75, QAR: 3.64, TRY: 47.5, ZAR: 16.5,
BRL: 5.071, MXN: 17.3, SEK: 9.512, NOK: 9.486, DKK: 6.467, PLN: 3.735,
CZK: 21, RUB: 79.6, EGP: 50.8, ILS: 3.052, LKR: 335, NPR: 153, BND: 1.282
};
var FX_ENDPOINTS = [
{
host: 'open.er-api.com',
url: 'https://open.er-api.com/v6/latest/USD',
parse: function (json) {
if (!json || !json.rates) {
return null;
}
return { rates: json.rates, date: json.time_last_update_utc || '' };
}
},
{
host: 'cdn.jsdelivr.net',
url: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json',
parse: function (json) {
if (!json || !json.usd) {
return null;
}
var rates = {};
for (var key in json.usd) {
rates[key.toUpperCase()] = json.usd[key];
}
return { rates: rates, date: json.date || '' };
}
},
{
host: 'api.exchangerate-api.com',
url: 'https://api.exchangerate-api.com/v4/latest/USD',
parse: function (json) {
if (!json || !json.rates) {
return null;
}
return { rates: json.rates, date: json.date || '' };
}
}
];
var CACHE_KEY = 'cpp-fx-usd';
var MARKER = 'data-cpp-signature';
var BADGE_CLASS = 'cpp-value-badge';
var PER_STAY_WORDS = ['住宿', 'stay', 'séjour', 'sejour', 'aufenthalt', 'estancia', 'soggiorno'];
var PER_NIGHT_WORDS = ['晚', 'night', 'nuit', 'nacht', 'noche', 'notte', '泊'];
var fx = { rates: STATIC_RATES, source: 'built-in snapshot', date: '2026-08-03' };
function log() {
if (CONFIG.debug) {
console.log.apply(console, ['[c/p]'].concat([].slice.call(arguments)));
}
}
function text(node) {
return node ? (node.textContent || '').trim() : '';
}
// Handles "16,500", "1.234,56" and "1,234.56" alike.
function parseAmount(raw) {
var cleaned = String(raw == null ? '' : raw).replace(/[^\d.,]/g, '');
if (!cleaned) {
return NaN;
}
var lastComma = cleaned.lastIndexOf(',');
var lastDot = cleaned.lastIndexOf('.');
var normalized;
if (lastComma > lastDot && cleaned.length - lastComma - 1 <= 2) {
normalized = cleaned.replace(/\./g, '').replace(',', '.');
} else {
normalized = cleaned.replace(/,/g, '');
}
var value = parseFloat(normalized);
return isFinite(value) ? value : NaN;
}
function containsAny(haystack, words) {
for (var i = 0; i < words.length; i++) {
if (haystack.indexOf(words[i]) > -1) {
return true;
}
}
return false;
}
// Marriott labels each figure as either per night ("╱晚") or per stay ("每次住宿").
function detectBasis(label, fallback) {
var lowered = label.toLowerCase();
if (containsAny(lowered, PER_STAY_WORDS)) {
return 'stay';
}
if (containsAny(lowered, PER_NIGHT_WORDS)) {
return 'night';
}
return fallback;
}
function toStayTotal(value, basis, nights) {
return basis === 'night' ? value * nights : value;
}
var SYMBOL_TO_CODE = {
'$': 'USD', '¥': 'CNY', '¥': 'CNY', '€': 'EUR', '£': 'GBP', '₩': 'KRW', '฿': 'THB', '₫': 'VND', '₹': 'INR'
};
function detectCurrency(label) {
var code = /\b([A-Z]{3})\b/.exec(label);
if (code && fx.rates[code[1]] !== undefined) {
return code[1];
}
for (var symbol in SYMBOL_TO_CODE) {
if (label.indexOf(symbol) > -1) {
return SYMBOL_TO_CODE[symbol];
}
}
return code ? code[1] : 'USD';
}
function parseDateParam(value) {
if (!value) {
return null;
}
var iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value);
if (iso) {
return Date.UTC(+iso[1], +iso[2] - 1, +iso[3]);
}
var us = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(value);
if (us) {
return Date.UTC(+us[3], +us[1] - 1, +us[2]);
}
return null;
}
function nightsFromPageData() {
var scripts = document.querySelectorAll('script[type="application/json"], script#__NEXT_DATA__');
for (var i = 0; i < scripts.length; i++) {
var match = /"lengthOfStay"\s*:\s*"?(\d+)"?/.exec(scripts[i].textContent || '');
if (match && +match[1] > 0) {
return +match[1];
}
}
return 0;
}
function getNights() {
var params = new URLSearchParams(location.search);
var from = parseDateParam(params.get('fromDate') || params.get('fromDateDefaultFormat'));
var to = parseDateParam(params.get('toDate') || params.get('toDateDefaultFormat'));
if (from !== null && to !== null) {
var diff = Math.round((to - from) / 86400000);
if (diff > 0) {
return diff;
}
}
return nightsFromPageData() || 1;
}
function valueTier(ratio) {
if (ratio >= CONFIG.gold) {
return 'gold';
}
if (ratio <= CONFIG.green) {
return 'green';
}
return 'red';
}
// ---- exchange rates -------------------------------------------------
function storageGet(key) {
try {
if (typeof GM_getValue === 'function') {
return GM_getValue(key, null);
}
return localStorage.getItem(key);
} catch (e) {
return null;
}
}
function storageSet(key, value) {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(key, value);
} else {
localStorage.setItem(key, value);
}
} catch (e) {
log('cache write failed', e);
}
}
// GM_xmlhttpRequest sidesteps CORS and any page CSP; fetch covers other managers.
function httpJson(url) {
return new Promise(function (resolve, reject) {
if (typeof GM_xmlhttpRequest === 'function') {
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 12000,
onload: function (response) {
try {
resolve(JSON.parse(response.responseText));
} catch (e) {
reject(e);
}
},
onerror: function () { reject(new Error('network error')); },
ontimeout: function () { reject(new Error('timeout')); }
});
return;
}
fetch(url, { credentials: 'omit', cache: 'no-store' })
.then(function (response) { return response.json(); })
.then(resolve, reject);
});
}
function ratesLookSane(rates) {
if (!rates || typeof rates !== 'object') {
return false;
}
if (!(rates.USD > 0.99 && rates.USD < 1.01)) {
return false;
}
return rates.CNY > 0 && rates.EUR > 0 && Object.keys(rates).length >= 20;
}
function fetchRatesFrom(index) {
if (index >= FX_ENDPOINTS.length) {
return Promise.reject(new Error('all FX endpoints failed'));
}
var endpoint = FX_ENDPOINTS[index];
return httpJson(endpoint.url)
.then(function (json) {
var parsed = endpoint.parse(json);
if (!parsed || !ratesLookSane(parsed.rates)) {
throw new Error('unusable payload');
}
parsed.source = endpoint.host;
return parsed;
})
.catch(function (error) {
log(endpoint.host, 'failed:', error.message);
return fetchRatesFrom(index + 1);
});
}
function adoptRates(payload) {
fx = { rates: payload.rates, source: payload.source, date: payload.date };
log('rates from', payload.source, payload.date, '- CNY', payload.rates.CNY);
}
function loadRates() {
var cached = null;
try {
cached = JSON.parse(storageGet(CACHE_KEY));
} catch (e) {
cached = null;
}
if (cached && ratesLookSane(cached.rates)) {
adoptRates(cached);
if (Date.now() - cached.ts < CONFIG.fxTtlHours * 3600000) {
return;
}
}
fetchRatesFrom(0).then(function (payload) {
adoptRates(payload);
payload.ts = Date.now();
storageSet(CACHE_KEY, JSON.stringify(payload));
schedule();
}, function (error) {
log('keeping fallback rates:', error.message);
});
}
// ---- annotation -----------------------------------------------------
function readRate(container) {
var pointsNode = container.querySelector('.points-value');
var priceNode = container.querySelector('.price-value');
if (!pointsNode || !priceNode) {
return null;
}
var points = parseAmount(text(pointsNode));
var price = parseAmount(text(priceNode));
if (!(points > 0) || !(price > 0)) {
return null;
}
var pointsLabel = text(container.querySelector('.points-currency-label'));
var currencyLabel = text(container.querySelector('.currency-section .currency-label')) ||
text(container.querySelector('.currency-label'));
return {
points: points,
price: price,
pointsBasis: detectBasis(pointsLabel, 'stay'),
priceBasis: detectBasis(currencyLabel, 'night'),
currency: detectCurrency(currencyLabel),
taxInclusive: !!container.querySelector('.taxes-and-fees-included')
};
}
function renderBadge(container, ratio, rate, nights, ratePerUsd) {
var badge = container.querySelector('.' + BADGE_CLASS);
if (!badge) {
badge = document.createElement('div');
badge.className = BADGE_CLASS;
container.appendChild(badge);
}
// Tier off the rounded figure, so a badge reading exactly 0.450 can never
// be coloured as though it were above the cut-off.
var shown = ratio.toFixed(3);
var tier = valueTier(parseFloat(shown));
badge.textContent = shown + ' c/p';
// width/align-self keep the gold chip hugging its text even if Marriott
// turns the surrounding container into a flex column.
badge.style.cssText =
'margin-top:4px;display:inline-block;width:fit-content;align-self:flex-start;' +
'font-weight:700;font-size:12px;line-height:1.4;white-space:nowrap;' + TIER_STYLE[tier];
var verdict = {
gold: 'great value — redeem',
red: 'below what a point is worth',
green: 'poor value — pay cash'
};
var cash = toStayTotal(rate.price, rate.priceBasis, nights);
var points = toStayTotal(rate.points, rate.pointsBasis, nights);
badge.title =
verdict[tier] +
'\n' + cash.toLocaleString() + ' ' + rate.currency + ' vs ' + points.toLocaleString() + ' points' +
' over ' + nights + ' night' + (nights === 1 ? '' : 's') +
(rate.taxInclusive ? ' (cash incl. taxes & fees)' : '') +
(rate.currency === 'USD' ? '' :
'\nFX ' + ratePerUsd + ' ' + rate.currency + '/USD') +
'\n' + fx.source + (fx.date ? ' · ' + fx.date : '');
}
function analyze() {
var containers = document.querySelectorAll('.rate-container');
if (!containers.length) {
return;
}
var nights = getNights();
var buckets = {};
var counted = 0;
for (var i = 0; i < containers.length; i++) {
var container = containers[i];
var rate = readRate(container);
if (!rate) {
continue;
}
var ratePerUsd = fx.rates[rate.currency];
if (!ratePerUsd) {
log('unknown currency', rate.currency);
continue;
}
var cashUsd = toStayTotal(rate.price, rate.priceBasis, nights) / ratePerUsd;
var totalPoints = toStayTotal(rate.points, rate.pointsBasis, nights);
var ratio = (cashUsd / totalPoints) * 100;
if (!isFinite(ratio) || ratio <= 0) {
continue;
}
var bucket = (Math.floor(ratio * 10) / 10).toFixed(1);
buckets[bucket] = (buckets[bucket] || 0) + 1;
counted++;
// React reuses these nodes, and rates arrive asynchronously, so the
// signature covers both the card contents and the FX rate applied.
var signature = [rate.points, rate.price, rate.currency, nights, ratePerUsd].join('|');
if (container.getAttribute(MARKER) !== signature) {
container.setAttribute(MARKER, signature);
renderBadge(container, ratio, rate, nights, ratePerUsd);
}
}
if (counted && CONFIG.debug) {
var sorted = Object.keys(buckets)
.sort(function (a, b) { return parseFloat(a) - parseFloat(b); })
.reduce(function (acc, key) { acc[key] = buckets[key]; return acc; }, {});
log(counted + ' rates over ' + nights + ' night(s)', sorted);
}
}
var pending = null;
function schedule() {
clearTimeout(pending);
pending = setTimeout(analyze, 200);
}
loadRates();
schedule();
new MutationObserver(schedule).observe(document.documentElement, {
childList: true,
subtree: true
});
})();