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