闲鱼页面价格分布图

从当前闲鱼页面生成价格分布图,生成的小窗可拖动。

  1. // ==UserScript==
  2. // @name Goofish Price Distribution Graph
  3. // @name:zh-CN 闲鱼页面价格分布图
  4. // @name:zh-TW 闲鱼頁面价格分布圖
  5. // @namespace http://tampermonkey.net/
  6. // @version 1.02
  7. // @description Extract prices and display a distribution graph in a draggable popup.
  8. // @description:zh-CN 从当前闲鱼页面生成价格分布图,生成的小窗可拖动。
  9. // @description:zh-TW 从當前闲鱼頁面生成价格分布圖,生成的小窗可拖動。
  10. // @author AAur
  11. // @match *://*.goofish.com/*
  12. // @grant none
  13. // @icon https://img.alicdn.com/tfs/TB19WObTNv1gK0jSZFFXXb0sXXa-144-144.png
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. // Extract prices from spans with class like "number--{random ID}", display a distribution graph in a draggable popup.
  18. // It's all about what is on your 1st page.
  19.  
  20. // 1.02: Remove all the outliers(geq(or leq) than median times upperBoundRatio(or lowerBoundRatio)) in search page
  21.  
  22. (function() {
  23. 'use strict';
  24.  
  25. let chartInstance = null;
  26. let allPrices = [];
  27.  
  28. let upperBoundRatio = 1.6;
  29. let lowerBoundRatio = 0.5;
  30.  
  31. // --- Utility: Dynamically load external JS (Chart.js) ---
  32. function loadScript(url, callback) {
  33. const script = document.createElement('script');
  34. script.src = url;
  35. script.onload = callback;
  36. script.onerror = function() {
  37. console.error('Failed to load script:', url);
  38. };
  39. document.head.appendChild(script);
  40. }
  41.  
  42. function removeOutliers(prices, upperBoundRatio, lowerBoundRatio) {
  43. prices.sort((a, b) => a - b);
  44. let median = prices[Math.round(prices.length / 2)];console.log(median);
  45. while (prices.length > 1) {
  46. let last = prices[prices.length - 1];
  47.  
  48. if (last >= median * upperBoundRatio) {
  49. prices.pop(); // 移除极端值
  50. } else {
  51. break; // 退出循环
  52. }
  53. }
  54. while (prices.length > 1) {
  55. if (prices[1] <= median * (1 - lowerBoundRatio)) {
  56. prices.shift(); // 移除极端值
  57. } else {
  58. break; // 退出循环
  59. }
  60. }
  61. }
  62.  
  63. // --- Extraction: Get all prices from span elements with class starting with "number--" ---
  64. function extractPrices() {
  65. const containers = document.querySelectorAll('div[class^="row3-wrap-price--"]');
  66. const prices = [];
  67. containers.forEach(container => {
  68. // Check for any descendant span with class "magnitude--EJxoo1DV" and text "万"
  69. const magnitudeSpan = container.querySelector('span.magnitude--EJxoo1DV');
  70. // Get the descendant span with class starting with "number--"
  71. const numberSpan = container.querySelector('span[class^="number--"]');
  72. const decimalSpan = container.querySelector('span[class^="decimal--"]');
  73. if (numberSpan) {
  74. let number = numberSpan.textContent.replace(/[^0-9\.]+/g, '') + decimalSpan.textContent.replace(/[^0-9\.]+/g, '');
  75. let value = parseFloat(number);
  76. if (!isNaN(value)) {
  77. if (magnitudeSpan && magnitudeSpan.textContent.trim() === '万') {
  78. value *= 10000;
  79. }
  80. prices.push(value);
  81. }
  82. }
  83. });
  84. // Remove Outlier in search page
  85. if(window.location.href.startsWith("https://www.goofish.com/search")) {
  86. removeOutliers(prices, upperBoundRatio, lowerBoundRatio);
  87. }
  88. return prices;
  89. }
  90.  
  91. // --- Binning: Create histogram data with constant intervals (multiples of 5) ---
  92. // options: { binCount: number, fixedBinSize: number (optional) }
  93. // Returns: { bins: string[], counts: number[], binSize: number }
  94. function computeHistogram(prices, options = {}) {
  95. let bins, counts, binSize;
  96. if (options.fixedBinSize) {
  97. const fixedBinSize = options.fixedBinSize;
  98. const minPrice = Math.min(...prices);
  99. const maxPrice = Math.max(...prices);
  100. const start = Math.floor(minPrice / fixedBinSize) * fixedBinSize;
  101. const end = Math.ceil(maxPrice / fixedBinSize) * fixedBinSize;
  102. const binCount = Math.ceil((end - start) / fixedBinSize);
  103. const edges = [];
  104. for (let i = 0; i <= binCount; i++) {
  105. edges.push(start + i * fixedBinSize);
  106. }
  107. counts = new Array(edges.length - 1).fill(0);
  108. prices.forEach(price => {
  109. let index = Math.floor((price - start) / fixedBinSize);
  110. if (index < 0) index = 0;
  111. if (index >= counts.length) index = counts.length - 1;
  112. counts[index]++;
  113. });
  114. // Only show the starting number of each bin on the x-axis.
  115. bins = edges.slice(0, -1).map(e => `${e}`);
  116. binSize = fixedBinSize;
  117. } else {
  118. // Auto-generated bin size based on a default bin count of 10.
  119. const binCount = options.binCount || 10;
  120. const minPrice = Math.min(...prices);
  121. const maxPrice = Math.max(...prices);
  122. const start = Math.floor(minPrice / 5) * 5;
  123. const rawBinSize = (maxPrice - start) / binCount;
  124. binSize = Math.ceil(rawBinSize / 5) * 5 || 5;
  125. const edges = [];
  126. for (let i = 0; i <= binCount; i++) {
  127. edges.push(start + i * binSize);
  128. }
  129. counts = new Array(edges.length - 1).fill(0);
  130. prices.forEach(price => {
  131. let index = Math.floor((price - start) / binSize);
  132. if (index < 0) index = 0;
  133. if (index >= counts.length) index = counts.length - 1;
  134. counts[index]++;
  135. });
  136. // Only show the starting number of each bin.
  137. bins = edges.slice(0, -1).map(e => `${e}`);
  138. }
  139. return { bins, counts, binSize };
  140. }
  141.  
  142. // --- Update Chart: Recalculate histogram, update the chart instance, and display the current bin size ---
  143. function updateChart(fixedBinSize) {
  144. const histogram = computeHistogram(allPrices, { binCount: 10, fixedBinSize: fixedBinSize });
  145. chartInstance.data.labels = histogram.bins;
  146. chartInstance.data.datasets[0].data = histogram.counts;
  147. chartInstance.update();
  148. // Update the bin size label, if present.
  149. const binSizeLabel = document.getElementById('currentBinSizeLabel');
  150. if (binSizeLabel) {
  151. binSizeLabel.textContent = 'Current Bin Size: ' + histogram.binSize;
  152. }
  153. }
  154.  
  155. // --- Draggable Popup: Enable dragging functionality on an element via its titlebar ---
  156. function makeDraggable(draggableEl, handleEl) {
  157. let offsetX = 0, offsetY = 0, startX = 0, startY = 0;
  158. handleEl.style.cursor = 'move';
  159.  
  160. handleEl.addEventListener('mousedown', dragMouseDown);
  161.  
  162. function dragMouseDown(e) {
  163. e.preventDefault();
  164. startX = e.clientX;
  165. startY = e.clientY;
  166. document.addEventListener('mousemove', elementDrag);
  167. document.addEventListener('mouseup', closeDragElement);
  168. }
  169.  
  170. function elementDrag(e) {
  171. e.preventDefault();
  172. offsetX = startX - e.clientX;
  173. offsetY = startY - e.clientY;
  174. startX = e.clientX;
  175. startY = e.clientY;
  176. draggableEl.style.top = (draggableEl.offsetTop - offsetY) + "px";
  177. draggableEl.style.left = (draggableEl.offsetLeft - offsetX) + "px";
  178. }
  179.  
  180. function closeDragElement() {
  181. document.removeEventListener('mousemove', elementDrag);
  182. document.removeEventListener('mouseup', closeDragElement);
  183. }
  184. }
  185.  
  186. // --- Popup Creation: Create a draggable, professional popup with a titlebar, control buttons, and a bin size display ---
  187. function createPopup() {
  188. // Main overlay container
  189. const overlay = document.createElement('div');
  190. overlay.id = 'price-distribution-overlay';
  191. Object.assign(overlay.style, {
  192. position: 'fixed',
  193. top: '20%',
  194. left: '50%',
  195. transform: 'translateX(-50%)',
  196. zIndex: 10000,
  197. width: '680px',
  198. backgroundColor: '#f9f9f9',
  199. border: '1px solid #ccc',
  200. boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
  201. borderRadius: '8px',
  202. fontFamily: 'Arial, sans-serif'
  203. });
  204.  
  205. // Titlebar for dragging with key color #ad7aff
  206. const titlebar = document.createElement('div');
  207. Object.assign(titlebar.style, {
  208. backgroundColor: '#ad7aff',
  209. color: '#fff',
  210. padding: '10px 15px',
  211. borderTopLeftRadius: '8px',
  212. borderTopRightRadius: '8px',
  213. fontSize: '18px',
  214. fontWeight: 'bold',
  215. userSelect: 'none',
  216. position: 'relative'
  217. });
  218. titlebar.textContent = 'Price Distribution Graph';
  219.  
  220. // Larger close button for easier interaction
  221. const closeButton = document.createElement('span');
  222. closeButton.textContent = '×';
  223. Object.assign(closeButton.style, {
  224. position: 'absolute',
  225. top: '5px',
  226. right: '10px',
  227. cursor: 'pointer',
  228. fontSize: '24px',
  229. lineHeight: '24px'
  230. });
  231. closeButton.addEventListener('click', () => overlay.remove());
  232. titlebar.appendChild(closeButton);
  233.  
  234. overlay.appendChild(titlebar);
  235.  
  236. // Content container for controls and the chart
  237. const content = document.createElement('div');
  238. content.style.padding = '20px';
  239. content.style.backgroundColor = '#ffffff';
  240.  
  241. // --- Control Panel: Three buttons to change the bin size ---
  242. const controlPanel = document.createElement('div');
  243. controlPanel.style.marginBottom = '10px';
  244.  
  245. // Button style common to all control buttons
  246. const btnStyle = {
  247. marginRight: '10px',
  248. padding: '5px 10px',
  249. cursor: 'pointer',
  250. border: '1px solid #ad7aff',
  251. borderRadius: '4px',
  252. backgroundColor: '#ad7aff',
  253. color: '#fff'
  254. };
  255.  
  256. // Button: Fixed bin size 100
  257. const btn100 = document.createElement('button');
  258. btn100.textContent = 'Bin Size: 100';
  259. Object.assign(btn100.style, btnStyle);
  260. btn100.addEventListener('click', () => updateChart(100));
  261.  
  262. // Button: Fixed bin size 50
  263. const btn50 = document.createElement('button');
  264. btn50.textContent = 'Bin Size: 50';
  265. Object.assign(btn50.style, btnStyle);
  266. btn50.addEventListener('click', () => updateChart(50));
  267.  
  268. // Button: Auto-generated bin size
  269. const btnAuto = document.createElement('button');
  270. btnAuto.textContent = 'Auto Bin';
  271. Object.assign(btnAuto.style, btnStyle);
  272. btnAuto.addEventListener('click', () => updateChart(null));
  273.  
  274. controlPanel.appendChild(btn100);
  275. controlPanel.appendChild(btn50);
  276. controlPanel.appendChild(btnAuto);
  277. content.appendChild(controlPanel);
  278.  
  279. // Create a label element to display current bin size
  280. const binSizeLabel = document.createElement('span');
  281. binSizeLabel.id = 'currentBinSizeLabel';
  282. binSizeLabel.style.marginRight = '20px';
  283. binSizeLabel.style.fontWeight = 'bold';
  284. // Default text (will be updated when chart is rendered)
  285. binSizeLabel.textContent = 'Current Bin Size: auto';
  286. controlPanel.appendChild(binSizeLabel);
  287.  
  288. // Create canvas for Chart.js graph
  289. const canvas = document.createElement('canvas');
  290. canvas.id = 'priceDistributionChart';
  291. canvas.width = 640;
  292. canvas.height = 400;
  293. content.appendChild(canvas);
  294.  
  295. overlay.appendChild(content);
  296. document.body.appendChild(overlay);
  297.  
  298. // Make the overlay draggable via the titlebar
  299. makeDraggable(overlay, titlebar);
  300. }
  301.  
  302. // --- Main: Extract prices, compute histogram, and render the chart ---
  303. function renderChart() {
  304. const prices = extractPrices();
  305. if (prices.length === 0) {
  306. console.warn('No prices found on this page.');
  307. return;
  308. }
  309. allPrices = prices; // store globally for updates
  310. const histogram = computeHistogram(prices, { binCount: 10 });
  311. createPopup();
  312.  
  313. const ctx = document.getElementById('priceDistributionChart').getContext('2d');
  314. chartInstance = new Chart(ctx, {
  315. type: 'bar',
  316. data: {
  317. labels: histogram.bins,
  318. datasets: [{
  319. label: 'Price Distribution',
  320. data: histogram.counts,
  321. backgroundColor: 'rgba(173,122,255, 0.5)', // key color with transparency
  322. borderColor: 'rgba(173,122,255, 1)',
  323. borderWidth: 1
  324. }]
  325. },
  326. options: {
  327. scales: {
  328. x: {
  329. title: { display: true, text: 'Bin Start Value' },
  330. ticks: { maxRotation: 45, minRotation: 0 }
  331. },
  332. y: {
  333. title: { display: true, text: 'Frequency' },
  334. beginAtZero: true
  335. }
  336. },
  337. plugins: {
  338. legend: { display: false }
  339. }
  340. }
  341. });
  342. // Update the bin size label initially.
  343. updateChart(null);
  344. }
  345.  
  346. // --- Create a fixed start button on the page ---
  347. function createStartButton() {
  348. const btn = document.createElement('button');
  349. btn.id = 'startPriceGraphBtn';
  350. btn.textContent = 'Show Price Graph';
  351. Object.assign(btn.style, {
  352. position: 'fixed',
  353. bottom: '20px',
  354. right: '20px',
  355. zIndex: 10000,
  356. padding: '10px 15px',
  357. backgroundColor: '#ad7aff',
  358. color: '#fff',
  359. border: 'none',
  360. borderRadius: '5px',
  361. boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
  362. cursor: 'pointer',
  363. fontSize: '14px'
  364. });
  365. btn.addEventListener('mouseenter', () => btn.style.backgroundColor = '#8a5cd6');
  366. btn.addEventListener('mouseleave', () => btn.style.backgroundColor = '#ad7aff');
  367. btn.addEventListener('click', () => {
  368. if (document.getElementById('price-distribution-overlay')) {
  369. console.warn('Graph already open.');
  370. return;
  371. }
  372. renderChart();
  373. });
  374. if (window.self === window.top) {
  375. document.body.appendChild(btn);
  376. }
  377. }
  378.  
  379. // --- Load Chart.js then create the start button ---
  380. loadScript('https://cdn.jsdelivr.net/npm/chart.js', createStartButton);
  381.  
  382. })();