eBay Shipping Cost Calculator

Adds shipping cost to item price in eBay search results

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         eBay Shipping Cost Calculator
// @namespace    http://tampermonkey.net/
// @version      2.4
// @description  Adds shipping cost to item price in eBay search results
// @author       none
// @match        https://www.ebay.com/sch/*
// @icon         https://www.ebay.com/favicon.ico
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // --- Config / selectors ---
    const SELECTORS = {
        container: '.srp-river-main',
        item: 'li.s-item, .s-item, .s-card, [data-view], [role="listitem"]',
        price: '.s-card__price, .s-item__price, .s-item__detail .s-item__price',
        totalClass: 's-item__total',
    };

    const RE = {
        anyMoney: /\$\s*([\d,]+(?:\.\d{1,2})?)/g,
        singleMoney: /\$\s*([\d,]+(?:\.\d{1,2})?)/,
        plusMoney: /(?:\+|plus)\s*\$\s*([\d,]+(?:\.\d{1,2})?)/i,
        shippingWords: /\b(shipping|delivery|ship)\b/i,
        freeShipping: /free\s*shipping|free$/i
    };

    // --- Settings (persisted) ---
    const settings = {
        taxRate: Number(localStorage.getItem('ebayTaxRate') || '0'),
        color: localStorage.getItem('ebayTotalColor') || '#e42648',
        fontSize: localStorage.getItem('ebayTotalFontSize') || '18',
        currencyRate: Number(localStorage.getItem('ebayCurrencyRate') || '0'),
        currencyLabel: localStorage.getItem('ebayCurrencyLabel') || ''
    };

    // --- Utilities ---
    function toNumber(str) {
        if (str == null) return null;
        return parseFloat(String(str).replace(/[,$\s]/g, ''));
    }

    function formatMoney(n) {
        return '$' + Number(n).toFixed(2);
    }

    // Extract monetary values from a string; returns array of numbers in order
    function extractMoneyValues(text) {
        const out = [];
        if (!text) return out;
        let m;
        while ((m = RE.anyMoney.exec(text)) !== null) {
            out.push(toNumber(m[1]));
        }
        return out;
    }

    // Try to determine price and shipping from an item element
    // Returns { price: number|null, shipping: number|null }
    function detectPriceAndShipping(item) {
        let price = null;
        let shipping = null;

        const itemText = item.textContent || '';

        // Quick global check for '+$X' anywhere in the item text (covers +$1.97 delivery cases)
        const globalPlus = itemText.match(RE.plusMoney);
        if (globalPlus) {
            shipping = toNumber(globalPlus[1]);
        }

        // 1) Try to find explicit price element
        const priceEl = item.querySelector(SELECTORS.price);
        if (priceEl) {
            const t = priceEl.textContent || '';
            const p = t.match(RE.singleMoney);
            if (p) price = toNumber(p[1]);
        }

        // 2) Scan for obvious shipping nodes: elements that contain '+' or 'delivery' or 'shipping'
        const potential = Array.from(item.querySelectorAll('span,div,li,p,small'));

        for (const el of potential) {
            const txt = (el.textContent || '').trim();
            if (!txt) continue;
            // free shipping
            if (RE.freeShipping.test(txt)) { shipping = 0; break; }
            // explicit +$X
            const plus = txt.match(RE.plusMoney);
            if (plus) { shipping = toNumber(plus[1]); break; }
            // words indicating shipping/delivery
            if (RE.shippingWords.test(txt)) {
                // try to extract numeric value
                const vals = extractMoneyValues(txt);
                if (vals.length === 1) { shipping = vals[0]; break; }
                // if none or multiple, continue scanning
            }
        }

        // 3) If price missing, attempt to pick the largest $ amount on the whole item as price
    // (itemText already defined above)
    const allValues = extractMoneyValues(itemText);
        if (price == null && allValues.length > 0) {
            price = Math.max(...allValues);
        }

        // 4) If shipping still missing, try heuristics: prefer smaller distinct amount or next after '+' near text
    if (shipping == null && allValues.length > 0) {
            // if there are exactly 2 different values, assume smaller is shipping
            const uniq = Array.from(new Set(allValues)).sort((a, b) => a - b);
            if (uniq.length >= 2) {
                // if price is known and equals largest, take next smaller; else take smallest
                if (price != null && Math.abs(price - uniq[uniq.length - 1]) < 0.001) {
                    shipping = uniq[uniq.length - 2];
                } else {
                    const candidate = uniq.find(v => v > 0 && v !== price);
                    if (candidate) shipping = candidate;
                }
            }
        }

        // 5) Final fallback: search near price element for any $ amount that's not equal to price
        if (shipping == null && price != null && priceEl) {
            const nearbyText = (priceEl.parentElement && priceEl.parentElement.textContent) || '';
            const nearVals = extractMoneyValues(nearbyText).filter(v => Math.abs(v - price) > 0.001);
            if (nearVals.length > 0) shipping = nearVals.reduce((a, b) => Math.min(a, b));
        }



        return { price: price || null, shipping: shipping || null };
    }

    // Render/update total element for an item
    function renderTotal(item, price, shipping) {
        if (price == null || shipping == null) return;
        let total = price + shipping;
        if (settings.taxRate && settings.taxRate > 0) total = total * (1 + settings.taxRate / 100);

        // find existing total element
        let totalEl = item.querySelector('.' + SELECTORS.totalClass);
        if (!totalEl) {
            totalEl = document.createElement('div');
            totalEl.className = SELECTORS.totalClass;
            // insert after price element if possible
            const priceEl = item.querySelector(SELECTORS.price);
            if (priceEl && priceEl.parentElement) priceEl.parentElement.insertBefore(totalEl, priceEl.nextSibling);
            else item.appendChild(totalEl);
        }

        totalEl.dataset.total = total.toFixed(2);
        totalEl.textContent = `Total: ${formatMoney(total)}`;
        if (settings.currencyRate && settings.currencyRate > 0) {
            const converted = (total * settings.currencyRate).toFixed(2);
            const label = settings.currencyLabel ? settings.currencyLabel + ' ' : '';
            totalEl.textContent += ` (${label}${Number(converted).toFixed(2)})`;
        }
        totalEl.style.color = settings.color;
        totalEl.style.fontWeight = 'bold';
        totalEl.style.fontSize = settings.fontSize + 'px';
    }

    // Main loop: scan items and add totals
    function addShippingToPrices() {
        const scope = document.querySelector(SELECTORS.container) || document.body;
        const items = Array.from(scope.querySelectorAll(SELECTORS.item));
        for (const item of items) {
            if (item.classList && item.classList.contains('processed')) continue;
            const { price, shipping } = detectPriceAndShipping(item);
            if (price != null && shipping != null) renderTotal(item, price, shipping);
            item.classList.add('processed');
        }
    }

    // Simple settings UI: floating button + panel
    function createSettingsUI() {
        // If body isn't available yet, wait for DOMContentLoaded and retry
        if (!document.body) {
            document.addEventListener('DOMContentLoaded', createSettingsUI);
            return;
        }
        const btn = document.createElement('div');
        btn.id = 'ebay-settings-button';
        btn.style.position = 'fixed';
        btn.style.top = '12px';
        btn.style.right = '12px';
        btn.style.width = '34px';
        btn.style.height = '34px';
        btn.style.borderRadius = '50%';
        btn.style.background = settings.color;
        btn.style.zIndex = 2147483647;
        btn.style.cursor = 'pointer';
        btn.style.pointerEvents = 'auto';
        btn.style.boxShadow = '0 2px 6px rgba(0,0,0,0.25)';
        btn.style.backdropFilter = 'none';
        btn.setAttribute('title', 'eBay Shipping Settings (Alt+S)');
        btn.setAttribute('aria-label', 'eBay Shipping Settings');
        document.body.appendChild(btn);

        const panel = document.createElement('div');
        panel.id = 'ebay-shipping-settings';
        panel.style.position = 'fixed';
        panel.style.top = '44px';
        panel.style.right = '12px';
        panel.style.padding = '12px';
        panel.style.background = '#fff';
        panel.style.border = '1px solid #ccc';
        panel.style.zIndex = 2147483647;
        panel.style.display = 'none';
        panel.innerHTML = `
            <div style="font-weight:bold;margin-bottom:8px">Shipping Calculator Settings</div>
            Tax Rate (%): <input id="ebay-tax-rate" type="number" min="0" max="100" step="0.01" value="${settings.taxRate}" style="width:70px"><br>
            Color: <input id="ebay-total-color" type="color" value="${settings.color}"><br>
            Size: <input id="ebay-total-fontsize" type="number" min="12" max="36" value="${settings.fontSize}" style="width:70px"><br>
            Currency Rate: <input id="ebay-currency-rate" type="number" step="0.0001" value="${settings.currencyRate || ''}" style="width:90px"><br>
            Currency Label: <input id="ebay-currency-label" type="text" value="${settings.currencyLabel}" style="width:90px"><br>
        `;
    // append in a microtask to avoid races with other scripts
    Promise.resolve().then(() => document.body.appendChild(panel));

        btn.textContent = '⚙';
        btn.style.color = '#fff';
        btn.style.display = 'flex';
        btn.style.alignItems = 'center';
        btn.style.justifyContent = 'center';
        btn.style.fontSize = '14px';
        // stopPropagation so the document click handler doesn't immediately close the panel
        btn.addEventListener('click', (e) => { e.stopPropagation(); panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; });

        // keyboard shortcut Alt+S to toggle settings
        document.addEventListener('keydown', (ev) => {
            if (ev.altKey && ev.key && ev.key.toLowerCase() === 's') {
                ev.preventDefault();
                ev.stopPropagation();
                panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
            }
        });
        panel.addEventListener('click', e => e.stopPropagation());
        document.addEventListener('click', () => { panel.style.display = 'none'; });

        panel.querySelector('#ebay-tax-rate').addEventListener('input', function () { settings.taxRate = Number(this.value) || 0; localStorage.setItem('ebayTaxRate', settings.taxRate); rescan(); });
        panel.querySelector('#ebay-total-color').addEventListener('input', function () { settings.color = this.value; localStorage.setItem('ebayTotalColor', settings.color); rescanVisuals(); });
        panel.querySelector('#ebay-total-fontsize').addEventListener('input', function () { settings.fontSize = this.value; localStorage.setItem('ebayTotalFontSize', settings.fontSize); rescanVisuals(); });
        panel.querySelector('#ebay-currency-rate').addEventListener('input', function () { settings.currencyRate = Number(this.value) || 0; localStorage.setItem('ebayCurrencyRate', settings.currencyRate); rescan(); });
    panel.querySelector('#ebay-currency-label').addEventListener('input', function () { settings.currencyLabel = this.value; localStorage.setItem('ebayCurrencyLabel', settings.currencyLabel); rescanVisuals(); });
    }

    function rescanVisuals() {
        const scope = document.querySelector(SELECTORS.container) || document.body;
        const totals = scope.querySelectorAll('.' + SELECTORS.totalClass);
        totals.forEach(el => {
            el.style.color = settings.color;
            el.style.fontSize = settings.fontSize + 'px';
            // re-render text if numeric stored
            if (el.dataset && el.dataset.total) el.textContent = `Total: ${formatMoney(Number(el.dataset.total))}`;
        });
    }

    function rescan() {
        // remove processed and re-run
        const scope = document.querySelector(SELECTORS.container) || document.body;
        const sel = SELECTORS.item.split(',').map(s => s.trim());
        sel.forEach(s => {
            try { scope.querySelectorAll(s + '.processed').forEach(el => el.classList.remove('processed')); } catch (e) {}
        });
        addShippingToPrices();
    }

    // Observe container changes
    function initObserver() {
        const container = document.querySelector(SELECTORS.container);
        if (!container) return;
        const obs = new MutationObserver(() => setTimeout(addShippingToPrices, 200));
        obs.observe(container, { childList: true, subtree: true });
    }

    // Init
    createSettingsUI();
    addShippingToPrices();

    initObserver();

})();