Skip to content

Instantly share code, notes, and snippets.

@m0wer
Created May 6, 2025 13:47
Show Gist options
  • Select an option

  • Save m0wer/8da721b12e1ab7f0e08598b93621f4d5 to your computer and use it in GitHub Desktop.

Select an option

Save m0wer/8da721b12e1ab7f0e08598b93621f4d5 to your computer and use it in GitHub Desktop.
Fiat to Sats Converter Greasemonkey user script
// ==UserScript==
// @name Fiat to Sats Converter
// @namespace https://gist.github.com/m0wer/
// @version 1.4
// @license MIT
// @description Converts fiat currency prices (USD, EUR) to Bitcoin satoshis
// @author m0wer
// @match *://*/*
// @grant GM.xmlHttpRequest
// @grant GM_info
// @noframes
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
console.log(`Fiat to Sats Converter (v${GM_info?.script?.version || '1.4'}) initializing...`);
// Configuration
const REFRESH_INTERVAL = 60 * 60 * 1000; // Refresh exchange rates every hour
const MEMPOOL_API_URL = 'https://mempool.space/api/v1/prices';
// Store exchange rates
let exchangeRates = {
USD: null,
EUR: null
};
// Format numbers with commas as thousands separators
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
// Convert fiat amount to satoshis
function convertToSats(amount, currency) {
if (!exchangeRates[currency] || exchangeRates[currency] <= 0) { // Also check for valid rate
console.warn(`Fiat to Sats: Exchange rate for ${currency} not available or invalid for amount ${amount}. Rate: ${exchangeRates[currency]}`);
return null;
}
// 1 BTC = 100,000,000 satoshis
// exchangeRates[currency] is the price of 1 BTC in the given currency
const sats = Math.round((amount / exchangeRates[currency]) * 100000000);
console.log(`Fiat to Sats: Converted ${amount} ${currency} to ${sats} sats (Rate: ${exchangeRates[currency]})`);
return formatNumber(sats);
}
// Fetch current exchange rates
function fetchExchangeRates() {
console.log('Fiat to Sats: Attempting to fetch exchange rates using GM.xmlHttpRequest...');
if (typeof GM === 'undefined' || typeof GM.xmlHttpRequest !== 'function') {
console.error('Fiat to Sats: GM.xmlHttpRequest is not available! Cannot fetch rates. Make sure @grant GM.xmlHttpRequest is in the script header.');
return;
}
GM.xmlHttpRequest({
method: 'GET',
url: MEMPOOL_API_URL,
responseType: 'json',
timeout: 15000, // 15 seconds timeout
onload: function(response) {
try {
if (response.status !== 200) {
console.error(`Fiat to Sats: API request failed with status ${response.status}. Response:`, response.responseText);
return;
}
const data = response.response;
if (data && typeof data.USD === 'number' && typeof data.EUR === 'number') { // Check type more strictly
exchangeRates.USD = data.USD;
exchangeRates.EUR = data.EUR;
console.log('Fiat to Sats: Exchange rates updated successfully.', JSON.parse(JSON.stringify(exchangeRates))); // Log a copy
processPage(); // Process the page after getting rates
} else {
console.error('Fiat to Sats: Invalid or incomplete data received from API.', data);
}
} catch (error) {
console.error('Fiat to Sats: Error parsing exchange rates from API response.', error, 'Response Text:', response.responseText);
}
},
onerror: function(error) {
console.error('Fiat to Sats: Failed to fetch exchange rates due to a network or other error.', error);
},
ontimeout: function() {
console.error('Fiat to Sats: Request to fetch exchange rates timed out.');
},
onabort: function() {
console.error('Fiat to Sats: Request to fetch exchange rates aborted.');
}
});
}
// Regular expressions for detecting prices
const priceRegexes = {
// USD: matches $10, $10.99, 10 USD, USD 10, etc.
USD: /\$\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)|\b(\d+(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:USD|US\$|US dollars?)\b|\bUSD\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)/gi,
// EUR: matches €10, 10€, 10 EUR, EUR 10, etc.
// Handles comma or dot as decimal/thousands separators. Parsing logic will disambiguate.
// Group 1 (€NUM), Group 2 (NUM €), Group 3 (EUR NUM)
EUR: /€\s*(\d+(?:[.,]\d{3})*(?:[.,]\d{1,2})?)|\b(\d+(?:[.,]\d{3})*(?:[.,]\d{1,2})?)\s*(?:€|EUR|euros?)\b|\bEUR\s*(\d+(?:[.,]\d{3})*(?:[.,]\d{1,2})?)/gi
};
// Process text nodes to find and convert prices
function processTextNode(textNode) {
const originalText = textNode.nodeValue;
if (!originalText || originalText.trim() === '' || originalText.includes('sats)')) { // Avoid re-processing or empty nodes
return;
}
let newText = originalText;
let overallModified = false; // Tracks if any modification happened in this call
for (const currency in priceRegexes) {
if (!exchangeRates[currency]) {
// console.warn(`Fiat to Sats: Skipping processing for ${currency} as rate is not available.`);
continue;
}
const regex = new RegExp(priceRegexes[currency].source, 'gi'); // Create new RegExp to reset lastIndex for each currency
let currentRunModifiedText = ""; // Holds the text modified in the current currency pass
let lastIndex = 0;
let matchFoundInCurrencyPass = false;
let tempTextForThisCurrency = newText; // Use the latest version of newText from previous currency
let match;
// Optional detailed logging:
// console.log(`Fiat to Sats: [${currency}] Processing text: "${tempTextForThisCurrency.substring(0,100)}..." with regex: ${regex}`);
while ((match = regex.exec(tempTextForThisCurrency)) !== null) {
matchFoundInCurrencyPass = true;
currentRunModifiedText += tempTextForThisCurrency.substring(lastIndex, match.index);
const fullMatch = match[0];
let amountStr = match[1] || match[2] || match[3];
if (!amountStr) {
// This case should ideally not be hit if regex groups are correct
console.warn(`Fiat to Sats: No amount string captured for match "${fullMatch}" with ${currency} regex.`);
currentRunModifiedText += fullMatch;
lastIndex = regex.lastIndex;
continue;
}
// console.log(`Fiat to Sats: [${currency}] Matched price string "${fullMatch}". Extracted amount string: "${amountStr}"`);
let parsedAmount;
let normalizedAmountStr = amountStr;
// Normalize number string for parseFloat
// parseFloat expects dot as decimal separator and no thousands separators.
if (currency === 'EUR') {
const hasDot = normalizedAmountStr.includes('.');
const hasComma = normalizedAmountStr.includes(',');
if (hasDot && hasComma) {
// Handles "1.234,56" (dot thousand, comma decimal) -> "1234.56"
// And "1,234.56" (comma thousand, dot decimal) -> "1234.56"
if (normalizedAmountStr.lastIndexOf('.') < normalizedAmountStr.lastIndexOf(',')) {
normalizedAmountStr = normalizedAmountStr.replace(/\./g, '').replace(',', '.');
} else {
normalizedAmountStr = normalizedAmountStr.replace(/,/g, '');
}
} else if (hasComma) { // Only comma is present
// If comma is likely a decimal separator: "123,45" -> "123.45" or "12,3" -> "12.3"
// It must be the only comma and followed by 1 or 2 digits at the end.
if (normalizedAmountStr.match(/,\d{1,2}$/) && (normalizedAmountStr.indexOf(',') === normalizedAmountStr.lastIndexOf(','))) {
normalizedAmountStr = normalizedAmountStr.replace(',', '.');
} else { // Otherwise, comma is a thousands separator: "1,234" -> "1234" or "1,234,567" -> "1234567"
normalizedAmountStr = normalizedAmountStr.replace(/,/g, '');
}
} else if (hasDot) { // Only dot is present
// If a dot is a thousands separator (e.g., "1.234", "1.234.567") it needs removal.
// parseFloat handles "123.45" (decimal dot) correctly.
// A dot is a thousands separator if it's not followed by 1 or 2 digits at the string end (i.e., not a final decimal point).
const parts = normalizedAmountStr.split('.');
if (parts.length > 1) { // Ensure there is at least one dot
const lastPart = parts[parts.length - 1];
// If the last part after a dot is 3 digits long, and it's the only segment after dots, or all middle segments are 3 digits
// e.g. "1.234" -> "1234"; "1.234.567" -> "1234567"
// but not "123.45" or "123.4"
if (lastPart.length === 3) {
let allThousands = true;
for (let i = 0; i < parts.length - 1; i++) {
if (parts[i+1].length !== 3) {
allThousands = false;
break;
}
}
if (allThousands && !normalizedAmountStr.match(/\.\d{1,2}$/)) { // ensure it's not misinterpreting something like "1.234.56"
normalizedAmountStr = normalizedAmountStr.replace(/\./g, '');
}
}
// If like "123.456" (often used in some contexts as valid float), parseFloat handles it.
}
}
// If no separators ("5"), it's fine.
} else { // For USD (and other potential future currencies assuming comma is thousands)
normalizedAmountStr = normalizedAmountStr.replace(/,/g, '');
}
parsedAmount = parseFloat(normalizedAmountStr);
if (isNaN(parsedAmount)) {
console.warn(`Fiat to Sats: Could not parse amount from normalized "${normalizedAmountStr}" (original "${amountStr}"). Full match: "${fullMatch}"`);
currentRunModifiedText += fullMatch;
} else {
const sats = convertToSats(parsedAmount, currency);
if (sats !== null) {
currentRunModifiedText += `${fullMatch} (${sats} sats)`;
overallModified = true; // Mark that a conversion happened
} else {
currentRunModifiedText += fullMatch; // Sats conversion failed (e.g. rate missing), keep original
}
}
lastIndex = regex.lastIndex;
}
currentRunModifiedText += tempTextForThisCurrency.substring(lastIndex); // Add remaining text from this currency's pass
if (matchFoundInCurrencyPass) { // Only update newText if this currency actually did something
newText = currentRunModifiedText;
}
}
if (overallModified) { // Only update nodeValue if a conversion actually happened
// console.log(`Fiat to Sats: Modifying text node: "${originalText}" to "${newText}"`);
textNode.nodeValue = newText;
}
}
// Process Amazon price elements
function processAmazonPrices() {
// Find Amazon price elements
const amazonPriceElements = document.querySelectorAll('.a-price');
amazonPriceElements.forEach(priceElement => {
// Skip if already processed
if (priceElement.dataset.satsAdded === 'true') {
return;
}
// Check if there's an offscreen price element which has the full price
const offscreenElement = priceElement.querySelector('.a-offscreen');
if (offscreenElement) {
const priceText = offscreenElement.textContent.trim();
// Detect currency and extract amount
let amount = null;
let currency = null;
if (priceText.includes('€')) {
currency = 'EUR';
// Extract number from €XX,XX format
const match = priceText.match(/(\d+(?:[.,]\d+)*)[€\s]/);
if (match && match[1]) {
// Handle European format with commas as decimal separators
let amountStr = match[1].replace(/\./g, '').replace(',', '.');
amount = parseFloat(amountStr);
}
} else if (priceText.includes('$')) {
currency = 'USD';
// Extract number from $XX.XX format
const match = priceText.match(/\$\s*(\d+(?:[.,]\d+)*)/);
if (match && match[1]) {
// Handle US format with commas as thousands separators
let amountStr = match[1].replace(/,/g, '');
amount = parseFloat(amountStr);
}
}
if (amount !== null && !isNaN(amount) && currency && exchangeRates[currency]) {
const sats = convertToSats(amount, currency);
if (sats !== null) {
// Create a new element for the sats conversion
const satsElement = document.createElement('span');
satsElement.className = 'a-size-mini a-color-secondary';
satsElement.style.display = 'block';
satsElement.style.marginTop = '2px';
satsElement.textContent = `(${sats} sats)`;
// Insert the element after the price
priceElement.insertAdjacentElement('afterend', satsElement);
// Mark as processed
priceElement.dataset.satsAdded = 'true';
}
}
}
});
}
// Process Mercadona price elements
function processMercadonaPrices() {
// Find Mercadona price elements
const mercadonaPriceElements = document.querySelectorAll('.product-price__unit-price');
mercadonaPriceElements.forEach(priceElement => {
// Skip if already processed
if (priceElement.dataset.satsAdded === 'true') {
return;
}
const priceText = priceElement.textContent.trim();
// Mercadona uses EUR format: X,XX €
if (priceText.includes('€')) {
// Extract number from X,XX € format
const match = priceText.match(/(\d+(?:[.,]\d+)*)[€\s]/);
if (match && match[1]) {
// Handle European format with commas as decimal separators
let amountStr = match[1].replace(/\./g, '').replace(',', '.');
const amount = parseFloat(amountStr);
if (!isNaN(amount) && exchangeRates.EUR) {
const sats = convertToSats(amount, 'EUR');
if (sats !== null) {
// Create a new element for the sats conversion
const satsElement = document.createElement('p');
satsElement.className = 'product-price__extra-price footnote1-r';
satsElement.style.color = '#888';
satsElement.textContent = `(${sats} sats)`;
// For product detail view
if (priceElement.closest('.product-price')) {
priceElement.parentNode.appendChild(satsElement);
} else {
// Insert after the price element
priceElement.insertAdjacentElement('afterend', satsElement);
}
// Mark as processed
priceElement.dataset.satsAdded = 'true';
}
}
}
}
});
}
// Walk through the DOM and process text nodes
function processPage() {
if (!exchangeRates.USD && !exchangeRates.EUR) { // Check if ANY rate is available
console.log('Fiat to Sats: Exchange rates not available yet for processing page.');
// Consider fetching rates again if none are set after a delay
// setTimeout(fetchExchangeRates, 5000); // Optional: retry fetching if initial attempt was too early or failed
return;
}
console.log('Fiat to Sats: Processing page content...');
// Process Amazon and Mercadona specific price elements
processAmazonPrices();
processMercadonaPrices();
const textNodes = [];
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode: function (node) {
const parent = node.parentNode;
if (!parent || parent.nodeName === 'SCRIPT' || parent.nodeName === 'STYLE' ||
parent.nodeName === 'NOSCRIPT' || parent.nodeName === 'TEXTAREA' ||
parent.isContentEditable || parent.closest('[contenteditable="true"]')) {
return NodeFilter.FILTER_REJECT;
}
if (!node.nodeValue.trim() || node.nodeValue.includes('sats)')) { // Also check for already processed
return NodeFilter.FILTER_REJECT;
}
// Skip nodes inside already processed Amazon or Mercadona price elements
if (parent.closest('[data-sats-added="true"]')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
},
false // entityReferenceExpansion deprecated, this argument has no effect.
);
let node;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
console.log(`Fiat to Sats: Found ${textNodes.length} text nodes to process.`);
textNodes.forEach(processTextNode);
console.log('Fiat to Sats: Page processing finished.');
}
// Handle dynamic content changes
let processTimeout;
const debouncedProcessPage = () => {
clearTimeout(processTimeout);
processTimeout = setTimeout(() => {
console.log('Fiat to Sats: Re-processing page due to DOM changes.');
processPage();
}, 750); // Debounce processing
};
function observeDOMChanges() {
const observer = new MutationObserver((mutations) => {
let shouldProcess = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const addedNode of mutation.addedNodes) {
if (addedNode.nodeType === Node.TEXT_NODE && addedNode.nodeValue.trim() && !addedNode.nodeValue.includes('sats)')) {
shouldProcess = true; break;
} else if (addedNode.nodeType === Node.ELEMENT_NODE) {
// Check for added Amazon or Mercadona price elements
if (addedNode.querySelector) {
const hasAmazonPrice = addedNode.querySelector('.a-price');
const hasMercadonaPrice = addedNode.querySelector('.product-price__unit-price');
if (hasAmazonPrice || hasMercadonaPrice) {
shouldProcess = true; break;
}
}
if (!['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'BUTTON', 'A'].includes(addedNode.nodeName.toUpperCase()) && // Avoid common interactive elements that might change often
addedNode.textContent && addedNode.textContent.trim() && !addedNode.textContent.includes('sats)')) { // Check trimmed textContent
shouldProcess = true; break;
}
}
}
} else if (mutation.type === 'characterData') {
if (mutation.target.nodeType === Node.TEXT_NODE && mutation.target.nodeValue.trim() && !mutation.target.nodeValue.includes('sats)')) {
// Check parent to avoid reprocessing inside already processed parent or script/style
const parent = mutation.target.parentNode;
if (parent && !['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA'].includes(parent.nodeName.toUpperCase()) &&
!parent.isContentEditable && !parent.closest('[contenteditable="true"]') &&
!parent.closest('[data-sats-added="true"]')) {
shouldProcess = true;
}
}
}
if (shouldProcess) break;
}
if (shouldProcess) {
// console.log('Fiat to Sats: DOM changes detected, queueing reprocessing.');
debouncedProcessPage();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
console.log('Fiat to Sats: DOM Observer started.');
}
// Initialize the script
function init() {
fetchExchangeRates(); // This will call processPage on success
setInterval(fetchExchangeRates, REFRESH_INTERVAL);
// Start observing DOM changes after a delay to allow initial load & processing.
// Using requestAnimationFrame to wait for next paint might be smoother than fixed timeout for some pages.
if (window.requestAnimationFrame) {
window.requestAnimationFrame(() => setTimeout(observeDOMChanges, 1500)); // Slightly shorter delay
} else {
setTimeout(observeDOMChanges, 2500);
}
}
// Start the script
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
@m0wer

m0wer commented May 6, 2025

Copy link
Copy Markdown
Author

Some examples in real life:

Amazon
Mercadona

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment