Amazon CamelCamelCamel + Keepa Price Charts

Add CamelCamelCamel and Keepa price charts to Amazon product pages.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name            Amazon CamelCamelCamel + Keepa Price Charts
// @version         2.0.0
// @description     Add CamelCamelCamel and Keepa price charts to Amazon product pages.
// @author          miki.it
// @namespace       https://github.com/mikispag/userscripts/
// @homepage        https://github.com/mikispag/userscripts/
// @match           https://www.amazon.com/*
// @match           https://www.amazon.co.uk/*
// @match           https://www.amazon.de/*
// @match           https://www.amazon.fr/*
// @match           https://www.amazon.it/*
// @match           https://www.amazon.es/*
// @match           https://www.amazon.ca/*
// @match           https://www.amazon.co.jp/*
// @match           https://www.amazon.in/*
// @match           https://www.amazon.com.br/*
// @match           https://www.amazon.com.mx/*
// @match           https://www.amazon.com.au/*
// @match           https://www.amazon.nl/*
// @match           https://www.amazon.sg/*
// @match           https://www.amazon.ae/*
// @match           https://www.amazon.sa/*
// @match           https://www.amazon.se/*
// @match           https://www.amazon.pl/*
// @match           https://www.amazon.com.tr/*
// @match           https://www.amazon.eg/*
// @match           https://www.amazon.com.be/*
// @run-at          document-idle
// @grant           GM_addStyle
// @noframes
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_ID = 'ccc-keepa-charts';
    const DEBUG = false;

    // Domain mappings for CamelCamelCamel
    const CAMEL_COUNTRY_MAP = {
        'com': 'us',
        'co.uk': 'uk',
        'de': 'de',
        'fr': 'fr',
        'it': 'it',
        'es': 'es',
        'ca': 'ca',
        'co.jp': 'jp',
        'com.au': 'au',
        'com.br': 'br',
        'com.mx': 'mx',
        'in': 'in',
        'nl': 'nl',
        'se': 'se',
        'sg': 'sg',
        'pl': 'pl',
        'com.be': 'be',
        'com.tr': 'tr',
        'ae': 'ae',
        'sa': 'sa',
        'eg': 'eg'
    };

    // Domain IDs for Keepa API
    const KEEPA_DOMAIN_MAP = {
        'com': 1,
        'co.uk': 2,
        'de': 3,
        'fr': 4,
        'co.jp': 5,
        'ca': 6,
        'it': 8,
        'es': 9,
        'in': 10,
        'com.mx': 11,
        'com.br': 12,
        'com.au': 13,
        'nl': 14,
        'com.tr': 15,
        'ae': 16,
        'sg': 17,
        'sa': 18,
        'se': 19,
        'pl': 20,
        'eg': 21,
        'com.be': 22
    };

    // Possible parent elements to attach charts (in order of preference)
    const PARENT_SELECTORS = [
        '#centerCol',
        '#unifiedPrice_feature_div',
        '#corePrice_feature_div',
        '#corePriceDisplay_desktop_feature_div',
        '#apex_desktop',
        '#MediaMatrix',
        '#rightCol',
        '#ppd',
        '#dp-container'
    ];

    function log(...args) {
        if (DEBUG) console.log(`[${SCRIPT_ID}]`, ...args);
    }

    function warn(...args) {
        console.warn(`[${SCRIPT_ID}]`, ...args);
    }

    /**
     * Extract TLD from current hostname
     * Handles compound TLDs like co.uk, co.jp, com.au, etc.
     */
    function getTLD() {
        const hostname = window.location.hostname;
        const match = hostname.match(/amazon\.(.+)$/);
        if (!match) return null;
        return match[1];
    }

    /**
     * Get ASIN using multiple detection methods
     */
    function getASIN() {
        // Method 1: Hidden input field
        const asinInput = document.getElementById('ASIN');
        if (asinInput?.value) {
            log('ASIN found via #ASIN input');
            return asinInput.value;
        }

        // Method 2: data-asin attribute on product element
        const productDiv = document.getElementById('dp');
        if (productDiv?.dataset?.asin) {
            log('ASIN found via #dp data-asin');
            return productDiv.dataset.asin;
        }

        // Method 3: Any element with data-asin (first non-empty)
        const dataAsinElement = document.querySelector('[data-asin]:not([data-asin=""])');
        if (dataAsinElement?.dataset?.asin) {
            log('ASIN found via [data-asin] selector');
            return dataAsinElement.dataset.asin;
        }

        // Method 4: URL pattern /dp/ASIN or /gp/product/ASIN
        const urlMatch = window.location.pathname.match(/\/(?:dp|gp\/product|gp\/aw\/d)\/([A-Z0-9]{10})/i);
        if (urlMatch) {
            log('ASIN found via URL pattern');
            return urlMatch[1];
        }

        // Method 5: Canonical link
        const canonical = document.querySelector('link[rel="canonical"]');
        if (canonical) {
            const canonicalMatch = canonical.href.match(/\/dp\/([A-Z0-9]{10})/i);
            if (canonicalMatch) {
                log('ASIN found via canonical link');
                return canonicalMatch[1];
            }
        }

        // Method 6: Detail page JSON (LD+JSON)
        const ldJsonScripts = document.querySelectorAll('script[type="application/ld+json"]');
        for (const script of ldJsonScripts) {
            try {
                const data = JSON.parse(script.textContent);
                if (data?.sku) {
                    log('ASIN found via LD+JSON');
                    return data.sku;
                }
            } catch (e) {
                // Ignore parsing errors
            }
        }

        return null;
    }

    /**
     * Find suitable parent element for chart injection
     */
    function findParentElement() {
        for (const selector of PARENT_SELECTORS) {
            const element = document.querySelector(selector);
            if (element) {
                log('Parent element found:', selector);
                return element;
            }
        }
        return null;
    }

    /**
     * Check if we're on a product page
     */
    function isProductPage() {
        return (
            window.location.pathname.includes('/dp/') ||
            window.location.pathname.includes('/gp/product/') ||
            window.location.pathname.includes('/gp/aw/d/') ||
            document.getElementById('dp') !== null ||
            document.getElementById('ppd') !== null
        );
    }

    /**
     * Add styles for the chart container
     */
    function addStyles() {
        const css = `
            #${SCRIPT_ID}-container {
                margin: 16px 0;
                padding: 10px 16px;
                border: 1px solid #ddd;
                border-radius: 8px;
                background: #fafafa;
            }

            #${SCRIPT_ID}-container .chart-wrapper {
                margin-bottom: 12px;
                text-align: center;
            }

            #${SCRIPT_ID}-container .chart-wrapper:last-child {
                margin-bottom: 0;
            }

            #${SCRIPT_ID}-container .chart-title {
                font-size: 14px;
                font-weight: bold;
                margin-bottom: 8px;
                color: #333;
            }

            #${SCRIPT_ID}-container .chart-link {
                display: inline-block;
                text-decoration: none;
            }

            #${SCRIPT_ID}-container .chart-img {
                max-width: 100%;
                height: auto;
                border-radius: 4px;
                transition: opacity 0.3s ease;
            }

            #${SCRIPT_ID}-container .chart-img.loading {
                opacity: 0.5;
            }

            #${SCRIPT_ID}-container .chart-img.error {
                display: none;
            }

            #${SCRIPT_ID}-container .chart-error {
                display: none;
                color: #c00;
                font-size: 12px;
                padding: 20px;
                background: #fff0f0;
                border-radius: 4px;
            }

            #${SCRIPT_ID}-container .chart-wrapper.has-error .chart-error {
                display: block;
            }

            #${SCRIPT_ID}-container .collapse-toggle {
                cursor: pointer;
                user-select: none;
                display: flex;
                align-items: center;
                gap: 8px;
                font-weight: bold;
                margin-bottom: 12px;
                color: #0066c0;
            }

            #${SCRIPT_ID}-container .collapse-toggle:hover {
                color: #c45500;
                text-decoration: underline;
            }

            #${SCRIPT_ID}-container .collapse-toggle::before {
                content: '▼';
                font-size: 10px;
                transition: transform 0.2s ease;
            }

            #${SCRIPT_ID}-container.collapsed .collapse-toggle::before {
                transform: rotate(-90deg);
            }

            #${SCRIPT_ID}-container.collapsed .charts-content {
                display: none;
            }

            #${SCRIPT_ID}-container.collapsed .collapse-toggle {
                margin-bottom: 0;
            }
        `;

        if (typeof GM_addStyle !== 'undefined') {
            GM_addStyle(css);
        } else {
            const style = document.createElement('style');
            style.textContent = css;
            document.head.appendChild(style);
        }
    }

    /**
     * Create chart element with loading/error states
     */
    function createChartElement(title, linkUrl, imageUrl, altText) {
        const wrapper = document.createElement('div');
        wrapper.className = 'chart-wrapper';

        const titleDiv = document.createElement('div');
        titleDiv.className = 'chart-title';
        titleDiv.textContent = title;

        const link = document.createElement('a');
        link.className = 'chart-link';
        link.href = linkUrl;
        link.target = '_blank';
        link.rel = 'noopener noreferrer';
        link.title = `View on ${title}`;

        const img = document.createElement('img');
        img.className = 'chart-img loading';
        img.alt = altText;
        img.loading = 'lazy';

        const errorDiv = document.createElement('div');
        errorDiv.className = 'chart-error';
        errorDiv.textContent = `Unable to load ${title} chart. Click title to view on site.`;

        img.addEventListener('load', () => {
            img.classList.remove('loading');
        });

        img.addEventListener('error', () => {
            img.classList.add('error');
            wrapper.classList.add('has-error');
            log(`Failed to load image: ${imageUrl}`);
        });

        // Set src after event listeners
        img.src = imageUrl;

        link.appendChild(img);
        wrapper.appendChild(titleDiv);
        wrapper.appendChild(link);
        wrapper.appendChild(errorDiv);

        return wrapper;
    }

    /**
     * Main function to inject charts
     */
    function injectCharts() {
        // Prevent duplicate injection
        if (document.getElementById(`${SCRIPT_ID}-container`)) {
            log('Charts already injected');
            return;
        }

        if (!isProductPage()) {
            log('Not a product page, skipping');
            return;
        }

        const asin = getASIN();
        if (!asin) {
            warn('Could not find ASIN on this page');
            return;
        }

        const tld = getTLD();
        if (!tld) {
            warn('Could not determine Amazon TLD');
            return;
        }

        const camelCountry = CAMEL_COUNTRY_MAP[tld];
        const keepaDomain = KEEPA_DOMAIN_MAP[tld];

        if (!camelCountry && !keepaDomain) {
            warn(`Unsupported Amazon region: ${tld}`);
            return;
        }

        const parentElement = findParentElement();
        if (!parentElement) {
            warn('Could not find suitable parent element');
            return;
        }

        log(`Injecting charts for ASIN: ${asin}, TLD: ${tld}`);

        addStyles();

        // Create main container
        const container = document.createElement('div');
        container.id = `${SCRIPT_ID}-container`;

        // Create collapsible header
        const toggle = document.createElement('div');
        toggle.className = 'collapse-toggle';
        toggle.textContent = 'Price History Charts';
        toggle.addEventListener('click', () => {
            container.classList.toggle('collapsed');
            // Save preference
            try {
                localStorage.setItem(`${SCRIPT_ID}-collapsed`, container.classList.contains('collapsed'));
            } catch (e) {
                // Ignore storage errors
            }
        });

        // Restore collapsed state
        try {
            if (localStorage.getItem(`${SCRIPT_ID}-collapsed`) === 'true') {
                container.classList.add('collapsed');
            }
        } catch (e) {
            // Ignore storage errors
        }

        const chartsContent = document.createElement('div');
        chartsContent.className = 'charts-content';

        // Add CamelCamelCamel chart
        if (camelCountry) {
            const camelChart = createChartElement(
                'CamelCamelCamel',
                `https://${camelCountry}.camelcamelcamel.com/product/${asin}`,
                `https://charts.camelcamelcamel.com/${camelCountry}/${asin}/amazon-new-used.png?force=1&zero=0&w=500&h=400&desired=false&legend=1&ilt=1&tp=all&fo=0`,
                `CamelCamelCamel price history for ${asin}`
            );
            chartsContent.appendChild(camelChart);
        }

        // Add Keepa chart
        if (keepaDomain) {
            const keepaChart = createChartElement(
                'Keepa',
                `https://keepa.com/#!product/${keepaDomain}-${asin}`,
                `https://graph.keepa.com/pricehistory.png?used=1&asin=${asin}&domain=${tld}`,
                `Keepa price history for ${asin}`
            );
            chartsContent.appendChild(keepaChart);
        }

        container.appendChild(toggle);
        container.appendChild(chartsContent);

        // Insert at beginning of parent element
        parentElement.insertBefore(container, parentElement.firstChild);

        log('Charts injected successfully');
    }

    /**
     * Initialize with retry logic for dynamic content
     */
    function init() {
        // Try immediately
        injectCharts();

        // If charts weren't injected, set up observer for dynamic content
        if (!document.getElementById(`${SCRIPT_ID}-container`) && isProductPage()) {
            log('Setting up MutationObserver for dynamic content');

            let attempts = 0;
            const maxAttempts = 10;

            const observer = new MutationObserver((mutations, obs) => {
                attempts++;

                if (document.getElementById(`${SCRIPT_ID}-container`)) {
                    obs.disconnect();
                    return;
                }

                if (attempts >= maxAttempts) {
                    obs.disconnect();
                    warn('Max attempts reached, giving up');
                    return;
                }

                injectCharts();
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });

            // Cleanup after 10 seconds regardless
            setTimeout(() => observer.disconnect(), 10000);
        }
    }

    // Handle SPA navigation (Amazon sometimes uses pushState)
    let lastUrl = location.href;
    const urlObserver = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            log('URL changed, reinitializing');
            // Small delay to let page update
            setTimeout(init, 500);
        }
    });

    urlObserver.observe(document.body, { childList: true, subtree: true });

    // Start
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();