闲鱼頁面价格分布圖

从當前闲鱼頁面生成价格分布圖,生成的小窗可拖動。

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Goofish Price Distribution Graph
// @name:zh-CN   闲鱼页面价格分布图
// @name:zh-TW   闲鱼頁面价格分布圖
// @namespace    http://tampermonkey.net/
// @version      1.02
// @description  Extract prices and display a distribution graph in a draggable popup.
// @description:zh-CN  从当前闲鱼页面生成价格分布图,生成的小窗可拖动。
// @description:zh-TW  从當前闲鱼頁面生成价格分布圖,生成的小窗可拖動。
// @author       AAur
// @match        *://*.goofish.com/*
// @grant        none
// @icon         https://img.alicdn.com/tfs/TB19WObTNv1gK0jSZFFXXb0sXXa-144-144.png
// @license      MIT
// ==/UserScript==

// Extract prices from spans with class like "number--{random ID}", display a distribution graph in a draggable popup.
// It's all about what is on your 1st page.

// 1.02: Remove all the outliers(geq(or leq) than median times upperBoundRatio(or lowerBoundRatio)) in search page

(function() {
    'use strict';

    let chartInstance = null;
    let allPrices = [];

    let upperBoundRatio = 1.6;
    let lowerBoundRatio = 0.5;

    // --- Utility: Dynamically load external JS (Chart.js) ---
    function loadScript(url, callback) {
        const script = document.createElement('script');
        script.src = url;
        script.onload = callback;
        script.onerror = function() {
            console.error('Failed to load script:', url);
        };
        document.head.appendChild(script);
    }

    function removeOutliers(prices, upperBoundRatio, lowerBoundRatio) {
        prices.sort((a, b) => a - b);
        let median = prices[Math.round(prices.length / 2)];console.log(median);
        while (prices.length > 1) {
            let last = prices[prices.length - 1];

            if (last >= median * upperBoundRatio) {
                prices.pop(); // 移除极端值
            } else {
                break; // 退出循环
            }
        }
        while (prices.length > 1) {
            if (prices[1] <= median * (1 - lowerBoundRatio)) {
                prices.shift(); // 移除极端值
            } else {
                break; // 退出循环
            }
        }
    }

    // --- Extraction: Get all prices from span elements with class starting with "number--" ---
    function extractPrices() {
        const containers = document.querySelectorAll('div[class^="row3-wrap-price--"]');
        const prices = [];
        containers.forEach(container => {
            // Check for any descendant span with class "magnitude--EJxoo1DV" and text "万"
            const magnitudeSpan = container.querySelector('span.magnitude--EJxoo1DV');
            // Get the descendant span with class starting with "number--"
            const numberSpan = container.querySelector('span[class^="number--"]');
            const decimalSpan = container.querySelector('span[class^="decimal--"]');
            if (numberSpan) {
                let number = numberSpan.textContent.replace(/[^0-9\.]+/g, '') + decimalSpan.textContent.replace(/[^0-9\.]+/g, '');
                let value = parseFloat(number);
                if (!isNaN(value)) {
                    if (magnitudeSpan && magnitudeSpan.textContent.trim() === '万') {
                        value *= 10000;
                    }
                    prices.push(value);
                }
            }
        });
        // Remove Outlier in search page
        if(window.location.href.startsWith("https://www.goofish.com/search")) {
            removeOutliers(prices, upperBoundRatio, lowerBoundRatio);
        }
        return prices;
    }

    // --- Binning: Create histogram data with constant intervals (multiples of 5) ---
    // options: { binCount: number, fixedBinSize: number (optional) }
    // Returns: { bins: string[], counts: number[], binSize: number }
    function computeHistogram(prices, options = {}) {
        let bins, counts, binSize;
        if (options.fixedBinSize) {
            const fixedBinSize = options.fixedBinSize;
            const minPrice = Math.min(...prices);
            const maxPrice = Math.max(...prices);
            const start = Math.floor(minPrice / fixedBinSize) * fixedBinSize;
            const end = Math.ceil(maxPrice / fixedBinSize) * fixedBinSize;
            const binCount = Math.ceil((end - start) / fixedBinSize);
            const edges = [];
            for (let i = 0; i <= binCount; i++) {
                edges.push(start + i * fixedBinSize);
            }
            counts = new Array(edges.length - 1).fill(0);
            prices.forEach(price => {
                let index = Math.floor((price - start) / fixedBinSize);
                if (index < 0) index = 0;
                if (index >= counts.length) index = counts.length - 1;
                counts[index]++;
            });
            // Only show the starting number of each bin on the x-axis.
            bins = edges.slice(0, -1).map(e => `${e}`);
            binSize = fixedBinSize;
        } else {
            // Auto-generated bin size based on a default bin count of 10.
            const binCount = options.binCount || 10;
            const minPrice = Math.min(...prices);
            const maxPrice = Math.max(...prices);
            const start = Math.floor(minPrice / 5) * 5;
            const rawBinSize = (maxPrice - start) / binCount;
            binSize = Math.ceil(rawBinSize / 5) * 5 || 5;
            const edges = [];
            for (let i = 0; i <= binCount; i++) {
                edges.push(start + i * binSize);
            }
            counts = new Array(edges.length - 1).fill(0);
            prices.forEach(price => {
                let index = Math.floor((price - start) / binSize);
                if (index < 0) index = 0;
                if (index >= counts.length) index = counts.length - 1;
                counts[index]++;
            });
            // Only show the starting number of each bin.
            bins = edges.slice(0, -1).map(e => `${e}`);
        }
        return { bins, counts, binSize };
    }

    // --- Update Chart: Recalculate histogram, update the chart instance, and display the current bin size ---
    function updateChart(fixedBinSize) {
        const histogram = computeHistogram(allPrices, { binCount: 10, fixedBinSize: fixedBinSize });
        chartInstance.data.labels = histogram.bins;
        chartInstance.data.datasets[0].data = histogram.counts;
        chartInstance.update();
        // Update the bin size label, if present.
        const binSizeLabel = document.getElementById('currentBinSizeLabel');
        if (binSizeLabel) {
            binSizeLabel.textContent = 'Current Bin Size: ' + histogram.binSize;
        }
    }

    // --- Draggable Popup: Enable dragging functionality on an element via its titlebar ---
    function makeDraggable(draggableEl, handleEl) {
        let offsetX = 0, offsetY = 0, startX = 0, startY = 0;
        handleEl.style.cursor = 'move';

        handleEl.addEventListener('mousedown', dragMouseDown);

        function dragMouseDown(e) {
            e.preventDefault();
            startX = e.clientX;
            startY = e.clientY;
            document.addEventListener('mousemove', elementDrag);
            document.addEventListener('mouseup', closeDragElement);
        }

        function elementDrag(e) {
            e.preventDefault();
            offsetX = startX - e.clientX;
            offsetY = startY - e.clientY;
            startX = e.clientX;
            startY = e.clientY;
            draggableEl.style.top = (draggableEl.offsetTop - offsetY) + "px";
            draggableEl.style.left = (draggableEl.offsetLeft - offsetX) + "px";
        }

        function closeDragElement() {
            document.removeEventListener('mousemove', elementDrag);
            document.removeEventListener('mouseup', closeDragElement);
        }
    }

    // --- Popup Creation: Create a draggable, professional popup with a titlebar, control buttons, and a bin size display ---
    function createPopup() {
        // Main overlay container
        const overlay = document.createElement('div');
        overlay.id = 'price-distribution-overlay';
        Object.assign(overlay.style, {
            position: 'fixed',
            top: '20%',
            left: '50%',
            transform: 'translateX(-50%)',
            zIndex: 10000,
            width: '680px',
            backgroundColor: '#f9f9f9',
            border: '1px solid #ccc',
            boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
            borderRadius: '8px',
            fontFamily: 'Arial, sans-serif'
        });

        // Titlebar for dragging with key color #ad7aff
        const titlebar = document.createElement('div');
        Object.assign(titlebar.style, {
            backgroundColor: '#ad7aff',
            color: '#fff',
            padding: '10px 15px',
            borderTopLeftRadius: '8px',
            borderTopRightRadius: '8px',
            fontSize: '18px',
            fontWeight: 'bold',
            userSelect: 'none',
            position: 'relative'
        });
        titlebar.textContent = 'Price Distribution Graph';

        // Larger close button for easier interaction
        const closeButton = document.createElement('span');
        closeButton.textContent = '×';
        Object.assign(closeButton.style, {
            position: 'absolute',
            top: '5px',
            right: '10px',
            cursor: 'pointer',
            fontSize: '24px',
            lineHeight: '24px'
        });
        closeButton.addEventListener('click', () => overlay.remove());
        titlebar.appendChild(closeButton);

        overlay.appendChild(titlebar);

        // Content container for controls and the chart
        const content = document.createElement('div');
        content.style.padding = '20px';
        content.style.backgroundColor = '#ffffff';

        // --- Control Panel: Three buttons to change the bin size ---
        const controlPanel = document.createElement('div');
        controlPanel.style.marginBottom = '10px';

        // Button style common to all control buttons
        const btnStyle = {
            marginRight: '10px',
            padding: '5px 10px',
            cursor: 'pointer',
            border: '1px solid #ad7aff',
            borderRadius: '4px',
            backgroundColor: '#ad7aff',
            color: '#fff'
        };

        // Button: Fixed bin size 100
        const btn100 = document.createElement('button');
        btn100.textContent = 'Bin Size: 100';
        Object.assign(btn100.style, btnStyle);
        btn100.addEventListener('click', () => updateChart(100));

        // Button: Fixed bin size 50
        const btn50 = document.createElement('button');
        btn50.textContent = 'Bin Size: 50';
        Object.assign(btn50.style, btnStyle);
        btn50.addEventListener('click', () => updateChart(50));

        // Button: Auto-generated bin size
        const btnAuto = document.createElement('button');
        btnAuto.textContent = 'Auto Bin';
        Object.assign(btnAuto.style, btnStyle);
        btnAuto.addEventListener('click', () => updateChart(null));

        controlPanel.appendChild(btn100);
        controlPanel.appendChild(btn50);
        controlPanel.appendChild(btnAuto);
        content.appendChild(controlPanel);

        // Create a label element to display current bin size
        const binSizeLabel = document.createElement('span');
        binSizeLabel.id = 'currentBinSizeLabel';
        binSizeLabel.style.marginRight = '20px';
        binSizeLabel.style.fontWeight = 'bold';
        // Default text (will be updated when chart is rendered)
        binSizeLabel.textContent = 'Current Bin Size: auto';
        controlPanel.appendChild(binSizeLabel);

        // Create canvas for Chart.js graph
        const canvas = document.createElement('canvas');
        canvas.id = 'priceDistributionChart';
        canvas.width = 640;
        canvas.height = 400;
        content.appendChild(canvas);

        overlay.appendChild(content);
        document.body.appendChild(overlay);

        // Make the overlay draggable via the titlebar
        makeDraggable(overlay, titlebar);
    }

    // --- Main: Extract prices, compute histogram, and render the chart ---
    function renderChart() {
        const prices = extractPrices();
        if (prices.length === 0) {
            console.warn('No prices found on this page.');
            return;
        }
        allPrices = prices; // store globally for updates
        const histogram = computeHistogram(prices, { binCount: 10 });
        createPopup();

        const ctx = document.getElementById('priceDistributionChart').getContext('2d');
        chartInstance = new Chart(ctx, {
            type: 'bar',
            data: {
                labels: histogram.bins,
                datasets: [{
                    label: 'Price Distribution',
                    data: histogram.counts,
                    backgroundColor: 'rgba(173,122,255, 0.5)', // key color with transparency
                    borderColor: 'rgba(173,122,255, 1)',
                    borderWidth: 1
                }]
            },
            options: {
                scales: {
                    x: {
                        title: { display: true, text: 'Bin Start Value' },
                        ticks: { maxRotation: 45, minRotation: 0 }
                    },
                    y: {
                        title: { display: true, text: 'Frequency' },
                        beginAtZero: true
                    }
                },
                plugins: {
                    legend: { display: false }
                }
            }
        });
        // Update the bin size label initially.
        updateChart(null);
    }

    // --- Create a fixed start button on the page ---
    function createStartButton() {
        const btn = document.createElement('button');
        btn.id = 'startPriceGraphBtn';
        btn.textContent = 'Show Price Graph';
        Object.assign(btn.style, {
            position: 'fixed',
            bottom: '20px',
            right: '20px',
            zIndex: 10000,
            padding: '10px 15px',
            backgroundColor: '#ad7aff',
            color: '#fff',
            border: 'none',
            borderRadius: '5px',
            boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
            cursor: 'pointer',
            fontSize: '14px'
        });
        btn.addEventListener('mouseenter', () => btn.style.backgroundColor = '#8a5cd6');
        btn.addEventListener('mouseleave', () => btn.style.backgroundColor = '#ad7aff');
        btn.addEventListener('click', () => {
            if (document.getElementById('price-distribution-overlay')) {
                console.warn('Graph already open.');
                return;
            }
            renderChart();
        });
        if (window.self === window.top) {
            document.body.appendChild(btn);
        }
    }

    // --- Load Chart.js then create the start button ---
    loadScript('https://cdn.jsdelivr.net/npm/chart.js', createStartButton);

})();