Created
May 6, 2025 13:47
-
-
Save m0wer/8da721b12e1ab7f0e08598b93621f4d5 to your computer and use it in GitHub Desktop.
Fiat to Sats Converter Greasemonkey user script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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(); | |
| } | |
| })(); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Some examples in real life: