- // ==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);
-
- })();