Standardize Instacart units to SI.
// ==UserScript==
// @name Instacart SI Units
// @namespace nikhilweee
// @match https://www.instacart.com/*
// @grant none
// @version 1.0
// @author nikhilweee
// @description Standardize Instacart units to SI.
// @icon https://www.instacart.com/favicon.ico
// ==/UserScript==
(function () {
"use strict";
function parseQuantity(val) {
let s = val.replace(/-/g, ' ').trim();
if (s.includes('/')) {
let parts = s.split(/\s+/);
if (parts.length === 2) {
let frac = parts[1].split('/');
return parseFloat(parts[0]) + (parseFloat(frac[0]) / parseFloat(frac[1]));
} else {
let frac = s.split('/');
return parseFloat(frac[0]) / parseFloat(frac[1]);
}
}
return parseFloat(s);
}
const CONFIG = {
units: [
{
// Mass
regex: /(\d+(?:[\s-]*\d+\/\d+|\/\d+|\.\d+)?)\s*(?:pounds?|lbs?)(?!\w)/gi,
factor: 0.453592,
unit: "kg"
},
{
regex: /(\d+(?:[\s-]*\d+\/\d+|\/\d+|\.\d+)?)\s*(?:ounces?|oz)(?!\w)/gi,
factor: 28.3495,
unit: "g"
},
// Length
{
regex: /(\d+(?:[\s-]*\d+\/\d+|\/\d+|\.\d+)?)\s*(?:inches?|in|")(?!\w)/gi,
factor: 2.54,
unit: "cm"
},
{
regex: /(\d+(?:[\s-]*\d+\/\d+|\/\d+|\.\d+)?)\s*(?:feet|ft|')(?!\w)/gi,
factor: 0.3048,
unit: "m"
},
{
regex: /(\d+(?:[\s-]*\d+\/\d+|\/\d+|\.\d+)?)\s*(?:miles?|mi)(?!\w)/gi,
factor: 1.60934,
unit: "km"
},
// Volume (common in grocery)
{
regex: /(\d+(?:[\s-]*\d+\/\d+|\/\d+|\.\d+)?)\s*(?:gallons?|gal)(?!\w)/gi,
factor: 3.78541,
unit: "L"
},
{
regex: /(\d+(?:[\s-]*\d+\/\d+|\/\d+|\.\d+)?)\s*(?:quarts?|qt)(?!\w)/gi,
factor: 0.946353,
unit: "L"
},
{
regex: /(\d+(?:[\s-]*\d+\/\d+|\/\d+|\.\d+)?)\s*(?:fluid\s*ounces?|fl\s*oz)(?!\w)/gi,
factor: 29.5735,
unit: "mL"
}
]
};
const SIUnits = {
init() {
this.observe();
// Initial process with a slight delay to allow framework to render
setTimeout(() => this.process(document.body), 1000);
},
process(root) {
if (!root) return;
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip script, style, etc.
const tag = node.parentNode.tagName;
if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'CODE', 'PRE'].includes(tag)) {
return NodeFilter.FILTER_REJECT;
}
if (node.textContent.trim().length === 0) {
return NodeFilter.FILTER_REJECT;
}
// Avoid double processing
if (node.parentNode.dataset.siProcessed) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
const nodes = [];
while (walker.nextNode()) {
nodes.push(walker.currentNode);
}
nodes.forEach(node => this.convertNode(node));
},
convertNode(node) {
let text = node.textContent;
let changed = false;
CONFIG.units.forEach(u => {
text = text.replace(u.regex, (match, val, offset, string) => {
// Check if already followed by the conversion
const nextPart = string.slice(offset + match.length);
if (nextPart.trim().startsWith(`(${u.unit}`)) return match;
changed = true;
const num = parseQuantity(val);
// Smart formatting: if > 1000g, use kg, etc? For now, stick to direct conversion
// Maybe round to reasonable decimals
let converted = num * u.factor;
// Formatting logic
if (u.unit === 'g' && converted >= 1000) {
converted = converted / 1000;
return `${match} (${converted.toFixed(2).replace(/\.00$/, '')} kg)`;
}
if (u.unit === 'mL' && converted >= 1000) {
converted = converted / 1000;
return `${match} (${converted.toFixed(2).replace(/\.00$/, '')} L)`;
}
return `${match} (${converted.toFixed(2).replace(/\.00$/, '')} ${u.unit})`;
});
});
if (changed) {
node.textContent = text;
}
},
observe() {
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes.forEach(n => {
if (n.nodeType === Node.ELEMENT_NODE) {
this.process(n);
} else if (n.nodeType === Node.TEXT_NODE) {
this.convertNode(n);
}
});
}
});
mo.observe(document.body, { childList: true, subtree: true });
}
};
SIUnits.init();
})();