// ==UserScript==
// @name 京东商品单价计算
// @namespace @lianginx/jd-product-unit-price
// @match https://cart.jd.com/cart_index*
// @icon https://storage.360buyimg.com/retail-mall/mall-common-component/favicon.ico
// @grant none
// @version 0.3.6
// @author lianginx
// @description 在京东购物车中识别猫粮、大米、小米商品并自动计算每公斤和每 500g 的单价,方便快速比较性价比。
// @downloadURL https://raw.githubusercontent.com/lianginx/browser-script/refs/heads/master/jd-product-unit-price/jd-product-unit-price.user.js
// @updateURL https://raw.githubusercontent.com/lianginx/browser-script/refs/heads/master/jd-product-unit-price/jd-product-unit-price.user.js
// @run-at document-idle
// ==/UserScript==
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
main();
}
async function main() {
const state = {
cartList: await waitForElement('[class*="_infiniteScroll_center_"]'),
cartObserver: null,
};
renderGoodsUnitPrice();
observeCartChanges(state);
observePageChanges(state);
}
function renderGoodsUnitPrice(scope = document) {
const goods = scope.matches?.('[class*="_product-item_"]')
? [scope]
: scope.querySelectorAll('[class*="_product-item_"]');
console.log('goods count: ', goods.length);
goods.forEach(good => {
try {
// 获取商品名称
const goodName = good.querySelector('[class*="_goodTitle_"][title]')?.getAttribute('title')?.trim() || '';
if (!isSupportedProduct(goodName)) return;
console.group(goodName);
// 从商品名称中提取重量
const totalKg = extractWeightKg(goodName);
if (!totalKg) return;
console.log('weight (kg): ', totalKg);
// 获取价格
const price = extractGoodPrice(good);
console.log('price: ', price);
if (!price) return;
// 计算单价
const unitPriceByKg = price / totalKg;
const unitPriceByJin = price / (totalKg * 2);
console.log('单价(公斤): ', unitPriceByKg);
console.log('单价(市斤): ', unitPriceByJin);
// 插入计算后的单价
if (!good.querySelector('.jd-cat-food-unit-price')) {
const priceAnchor = good.querySelector('[class*="_flexPriceAndPromotion_"] > div');
if (!priceAnchor) return;
const unitPrice = document.createElement('div');
unitPrice.className = 'jd-cat-food-unit-price';
unitPrice.style.fontSize = '12px';
unitPrice.style.color = '#333';
unitPrice.style.lineHeight = '1';
unitPrice.style.marginBottom = '4px';
unitPrice.innerHTML = `
¥${unitPriceByKg.toFixed(2)}/kg
|
¥${unitPriceByJin.toFixed(2)}/500g`;
priceAnchor.before(unitPrice);
}
// 更新计算后的单价
else {
const kg = good.querySelector('.jd-cat-food-unit-price .kg');
const kgText = `¥${unitPriceByKg.toFixed(2)}`;
if (kg && kg.textContent !== kgText) {
kg.textContent = kgText;
}
const jin = good.querySelector('.jd-cat-food-unit-price .jin');
const jinText = `¥${unitPriceByJin.toFixed(2)}`;
if (jin && jin.textContent !== jinText) {
jin.textContent = jinText;
}
}
} finally {
console.groupEnd();
}
});
}
function extractGoodPrice(good) {
const fullPrice = good.querySelector('[class*="_price-normal_"]')?.textContent?.trim();
if (!fullPrice) return null;
const price = parseFloat(fullPrice.replace(/¥|¥|,/g, ''));
return Number.isFinite(price) ? price : null;
}
function isSupportedProduct(goodName) {
if (/手机|REDMI|充电宝|电视|平板/i.test(goodName)) {
return false;
}
return /猫粮|狗粮|猫砂|(.)米/.test(goodName);
}
function observeCartChanges(state) {
state.cartObserver?.disconnect();
const observer = new MutationObserver(mutations => {
const dirtyGoods = new Set();
mutations.forEach(mutation => {
if (isSelfMutation(mutation.target)) return;
const changedNodes = [mutation.target, ...mutation.addedNodes, ...mutation.removedNodes];
changedNodes.forEach(node => {
const good = getGoodElement(node);
if (good) {
dirtyGoods.add(good);
return;
}
if (!(node instanceof Element)) return;
if (isSelfMutation(node)) return;
if (node.matches?.('[class*="_product-item_"]')) {
dirtyGoods.add(node);
}
});
});
dirtyGoods.forEach(good => {
renderGoodsUnitPrice(good);
});
});
observer.observe(state.cartList, {
childList: true,
subtree: true,
characterData: true,
});
state.cartObserver = observer;
}
function observePageChanges(state) {
const observer = new MutationObserver(mutations => {
const dirtyGoods = new Set();
let nextCartList = null;
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (!(node instanceof Element)) return;
if (isSelfMutation(node)) return;
const cartList = node.matches?.('[class*="_infiniteScroll_center_"]')
? node
: node.querySelector?.('[class*="_infiniteScroll_center_"]');
if (cartList) {
nextCartList = cartList;
}
const good = getGoodElement(node);
if (good) {
dirtyGoods.add(good);
return;
}
node.querySelectorAll?.('[class*="_product-item_"]').forEach(item => {
dirtyGoods.add(item);
});
});
});
if (nextCartList && nextCartList !== state.cartList) {
state.cartList = nextCartList;
observeCartChanges(state);
renderGoodsUnitPrice(nextCartList);
}
dirtyGoods.forEach(good => {
renderGoodsUnitPrice(good);
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
function getGoodElement(node) {
if (node instanceof Element) {
return node.closest('[class*="_product-item_"]');
}
const parent = node.parentElement;
if (!parent) return null;
return parent.closest('[class*="_product-item_"]');
}
function isSelfMutation(node) {
if (node instanceof Element) {
return node.classList.contains('jd-cat-food-unit-price')
|| Boolean(node.closest('.jd-cat-food-unit-price'));
}
const parent = node.parentElement;
if (!parent) return false;
return parent.classList.contains('jd-cat-food-unit-price')
|| Boolean(parent.closest('.jd-cat-food-unit-price'));
}
function extractWeightKg(goodName) {
const normalizedName = goodName.replace(/[/|]/g, '/');
const segmentWeights = normalizedName
.split(/[\/]/)
.map(extractWeightFromSegment)
.filter(weight => weight !== null);
if (segmentWeights.length > 0) {
return Math.max(...segmentWeights);
}
return extractWeightFromSegment(normalizedName);
}
function convertWeightToKg(value, unit) {
const normalizedUnit = unit.toLowerCase();
if (normalizedUnit === 'g') {
return value / 1000;
}
if (normalizedUnit === '斤') {
return value * 0.5;
}
return value;
}
function extractWeightFromSegment(segment) {
let maxWeight = null;
const multipliedPattern = /(\d+(?:\.\d+)?)\s*(kg|g|斤)\s*[*x×X]\s*(\d+(?:\.\d+)?)/g;
let multipliedMatch;
while ((multipliedMatch = multipliedPattern.exec(segment)) !== null) {
const value = Number.parseFloat(multipliedMatch[1]);
const unit = multipliedMatch[2];
const count = Number.parseFloat(multipliedMatch[3]);
if (!Number.isFinite(value) || !Number.isFinite(count)) continue;
const totalKg = convertWeightToKg(value, unit) * count;
maxWeight = maxWeight === null ? totalKg : Math.max(maxWeight, totalKg);
}
const packagePattern = /(\d+(?:\.\d+)?)\s*(kg|g|斤)\s*(?:款\s*)?(\d+(?:\.\d+)?)\s*(?:包装|包)/g;
let packageMatch;
while ((packageMatch = packagePattern.exec(segment)) !== null) {
const value = Number.parseFloat(packageMatch[1]);
const unit = packageMatch[2];
const count = Number.parseFloat(packageMatch[3]);
if (!Number.isFinite(value) || !Number.isFinite(count)) continue;
const totalKg = convertWeightToKg(value, unit) * count;
maxWeight = maxWeight === null ? totalKg : Math.max(maxWeight, totalKg);
}
const singlePattern = /(\d+(?:\.\d+)?)\s*(kg|g|KG|G|斤)/g;
let singleMatch;
while ((singleMatch = singlePattern.exec(segment)) !== null) {
const value = Number.parseFloat(singleMatch[1]);
const unit = singleMatch[2];
if (!Number.isFinite(value)) continue;
const totalKg = convertWeightToKg(value, unit);
maxWeight = maxWeight === null ? totalKg : Math.max(maxWeight, totalKg);
}
return maxWeight;
}
function waitForElement(selector) {
return new Promise(resolve => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
observer.disconnect();
resolve(el);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
isSupportedProduct,
extractWeightKg,
convertWeightToKg,
extractWeightFromSegment,
};
}