您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
The "Wilson index" in parkrun is the highest consecutive event number completed, starting from #1. This script calculates and displays a parkrunner's Wilson index on their results page.
// ==UserScript== // @name parkrun Wilson index display // @description The "Wilson index" in parkrun is the highest consecutive event number completed, starting from #1. This script calculates and displays a parkrunner's Wilson index on their results page. // @author Pete Johns (@johnsyweb) // @grant none // @homepage https://github.com/johnsyweb/tampermonkey-parkrun // @icon https://www.google.com/s2/favicons?sz=64&domain=parkrun.com.au // @license MIT // @match *://www.parkrun.ca/parkrunner/*/all/ // @match *://www.parkrun.co.at/parkrunner/*/all/ // @match *://www.parkrun.co.nl/parkrunner/*/all/ // @match *://www.parkrun.co.nz/parkrunner/*/all/ // @match *://www.parkrun.co.za/parkrunner/*/all/ // @match *://www.parkrun.com.au/parkrunner/*/all/ // @match *://www.parkrun.com.de/parkrunner/*/all/ // @match *://www.parkrun.dk/parkrunner/*/all/ // @match *://www.parkrun.fi/parkrunner/*/all/ // @match *://www.parkrun.fr/parkrunner/*/all/ // @match *://www.parkrun.ie/parkrunner/*/all/ // @match *://www.parkrun.it/parkrunner/*/all/ // @match *://www.parkrun.jp/parkrunner/*/all/ // @match *://www.parkrun.lt/parkrunner/*/all/ // @match *://www.parkrun.my/parkrunner/*/all/ // @match *://www.parkrun.no/parkrunner/*/all/ // @match *://www.parkrun.org.uk/parkrunner/*/all/ // @match *://www.parkrun.pl/parkrunner/*/all/ // @match *://www.parkrun.se/parkrunner/*/all/ // @match *://www.parkrun.sg/parkrunner/*/all/ // @match *://www.parkrun.us/parkrunner/*/all/ // @namespace http://tampermonkey.net/ // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js // @run-at document-end // @supportURL https://github.com/johnsyweb/tampermonkey-parkrun/issues/ // @tag parkrun // @version 2025-06-16 01:53 02:17 01:28 02:13 19:55 10:42 // ==/UserScript== (function () { 'use strict'; /** * Finds the last results table with a specified number of columns * @param {Document} document - The document object to search in * @param {number} [columnCount=7] - Required number of columns * @returns {HTMLTableElement|null} The matching table or null if not found */ function findResultsTable(document, columnCount = 7) { const tables = document.querySelectorAll('[id="results"]'); let matchingTable = null; for (const table of tables) { const firstRow = table.querySelector('tr'); if (firstRow) { const columns = firstRow.querySelectorAll('th, td').length; if (columns === columnCount) { matchingTable = table; break; } } } return matchingTable; } function extractEventDetails(table) { const rows = Array.from(table.querySelectorAll('tbody > tr')); return rows.reverse().map((row) => { const eventName = row.querySelector('td:nth-child(1)').textContent.trim(); const eventDate = row.querySelector('td:nth-child(2)').textContent.trim(); const eventNumber = row.querySelector('td:nth-child(3)').textContent.trim(); return { eventName, eventDate, eventNumber: parseInt(eventNumber, 10), }; }); } /** * Calculates the Wilson index, which represents the highest consecutive number of parkrun events * completed starting from 1. It iterates through the sorted event numbers and increments the index * as long as the next event number matches the expected value. * * @param {Array} events - An array of event objects containing event numbers. * @returns {number} The calculated Wilson index. */ function calculateWilsonIndex(events) { let wilsonIndex = 0; const eventNumbers = events.map((e) => e.eventNumber).sort((a, b) => a - b); for (const eventNumber of eventNumbers) { if (eventNumber >= wilsonIndex + 2) { break; } else if (eventNumber === wilsonIndex + 1) { wilsonIndex++; } } return wilsonIndex; } function calculateWilsonIndexOverTime(events) { const wilsonIndices = []; for (let i = 0; i < events.length; i++) { const subset = events.slice(0, i + 1); const parkruns = i + 1; const event = `${events[i].eventName} # ${events[i].eventNumber} on ${events[i].eventDate}`; const wilsonIndex = calculateWilsonIndex(subset); wilsonIndices.push({ parkruns, event, wilsonIndex }); } return wilsonIndices; } function createWilsonGraph(indices, container, athleteInfo) { const canvas = document.createElement('canvas'); canvas.style.width = '100%'; canvas.style.height = '300px'; container.appendChild(canvas); const ctx = canvas.getContext('2d'); // eslint-disable-next-line no-undef const chart = new Chart(ctx, { type: 'line', data: { labels: indices.map((i) => i.parkruns), datasets: [ { label: athleteInfo, data: indices.map((i) => ({ x: i.parkruns, y: i.wilsonIndex, event: i.event, })), borderColor: getDatasetColor(0), backgroundColor: '#2b223d', }, ], }, options: { responsive: true, scales: { y: { beginAtZero: true, title: { display: true, text: 'Wilson Index', }, suggestedMax: Math.ceil(Math.max(...indices.map((i) => i.wilsonIndex)) * 1.1), // Add 10% padding }, x: { title: { display: true, text: 'parkruns', }, min: 0, suggestedMax: Math.ceil(indices.length * 1.1), // Initial padding }, }, plugins: { title: { display: true, text: 'Wilson Index Progress', }, tooltip: { callbacks: { label: function (context) { const point = context.raw; return [`Wilson Index: ${point.y}`, `Event: ${point.event}`]; }, }, }, }, }, }); return chart; } /** * Fetches text content from a URI with caching support * @param {string} uri - The URI to fetch from * @param {string} cacheKey - The key to use for caching * @param {number} [cacheTtlMs=3600000] - Cache TTL in milliseconds (default: 1 hour) * @returns {Promise<string>} - The fetched text content */ async function fetchWithCache(uri, cacheKey, cacheTtlMs = 60 * 60 * 1000) { const cached = sessionStorage.getItem(cacheKey); if (cached) { const { data, timestamp } = JSON.parse(cached); const isFresh = Date.now() - timestamp < cacheTtlMs; if (isFresh) { return data; } } return fetch(uri) .then((response) => { if (!response.ok) { throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); } return response.text(); }) .then((text) => { sessionStorage.setItem( cacheKey, JSON.stringify({ data: text, timestamp: Date.now(), }) ); return text; }) .catch((error) => { console.error(`Error fetching ${uri}:`, error); if (cached) { console.warn('Using stale cached data after fetch failure'); return JSON.parse(cached).data; } throw error; }); } async function fetchFriendResults(athleteId) { const cacheKey = `parkrunner_${athleteId}_all`; const uri = `${window.location.origin}/parkrunner/${athleteId}/all/`; const text = await fetchWithCache(uri, cacheKey); const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const table = findResultsTable(doc); if (!table) { console.error('Friend results table not found'); return null; } const h2Element = doc.querySelector('h2'); if (!h2Element) { console.error('Friend H2 element not found'); return null; } const friendInfo = extractAthleteInfo(h2Element); if (!friendInfo) { console.error('Could not extract friend athlete info'); return null; } const friendEvents = extractEventDetails(table); const friendIndices = calculateWilsonIndexOverTime(friendEvents); return { friendIndices, friendInfo }; } function createComparisonUI(container, onCompare) { const form = document.createElement('form'); form.style.marginBottom = '20px'; form.style.textAlign = 'center'; const input = document.createElement('input'); input.style.width = '200px'; input.type = 'text'; input.placeholder = "Enter friend's athlete ID (e.g. A507)"; input.style.padding = '5px'; input.style.marginRight = '10px'; input.style.borderRadius = '3px'; input.style.border = '1px solid #ffa300'; input.style.backgroundColor = '#2b223d'; input.style.color = '#ffa300'; const button = document.createElement('button'); button.textContent = 'Compare'; button.style.padding = '5px 10px'; button.style.backgroundColor = '#ffa300'; button.style.color = '#2b223d'; button.style.border = 'none'; button.style.borderRadius = '3px'; button.style.cursor = 'pointer'; form.appendChild(input); form.appendChild(button); form.addEventListener('submit', async (e) => { e.preventDefault(); const athleteId = input.value.trim().replace(/^[aA]/, ''); if (!athleteId) return; button.disabled = true; button.textContent = 'Loading...'; try { const { friendIndices, friendInfo } = await fetchFriendResults(athleteId); onCompare(friendIndices, friendInfo); } catch (error) { console.error("Failed to fetch friend's results:", error); alert("Failed to fetch friend's results. Please check the ID and try again."); } finally { button.disabled = false; button.textContent = 'Compare'; } }); container.insertBefore(form, container.firstChild); } function updateChart(chart, friendIndices, friendInfo) { const datasetIndex = chart.data.datasets.length; const friendDataset = { label: friendInfo, data: friendIndices.map((i) => ({ x: i.parkruns, y: i.wilsonIndex, event: i.event, })), borderColor: getDatasetColor(datasetIndex), backgroundColor: '#2b223d', }; chart.data.datasets.push(friendDataset); chart.update(); const maxParkruns = Math.max( ...chart.data.datasets.flatMap((dataset) => dataset.data.map((d) => d.x)) ); const maxWilsonIndex = Math.max( ...chart.data.datasets.flatMap((dataset) => dataset.data.map((d) => d.y)) ); chart.options.scales.x.suggestedMax = Math.ceil(maxParkruns * 1.1); chart.options.scales.y.suggestedMax = Math.ceil(maxWilsonIndex * 1.1); chart.update(); } function extractAthleteInfo(h2Element) { return h2Element.textContent.trim(); } function getDatasetColor(index) { const colors = [ '#FFA300', '#90EE90', '#FF69B4', '#4169E1', '#FFD700', '#9370DB', '#20B2AA', '#FF6347', '#DDA0DD', '#00CED1', ]; return colors[index % colors.length]; } function displayWilsonIndex() { const table = findResultsTable(document); if (!table) { console.error('Results table not found'); return; } const h2Element = document.querySelector('h2'); if (!h2Element) { console.error('H2 element not found'); return; } const athleteInfo = extractAthleteInfo(h2Element); if (!athleteInfo) { console.error('Could not extract athlete info'); return; } const eventDetails = extractEventDetails(table); const wilsonIndex = calculateWilsonIndex(eventDetails); const wilsonIndices = calculateWilsonIndexOverTime(eventDetails); if (h2Element) { const container = document.createElement('div'); container.style.marginTop = '20px'; container.style.backgroundColor = '#2b223d'; container.style.padding = '20px'; container.style.borderRadius = '5px'; const wilsonElement = document.createElement('div'); wilsonElement.textContent = `Wilson index: ${wilsonIndex}`; wilsonElement.style.fontSize = '1.5em'; wilsonElement.style.color = '#ffa300'; wilsonElement.style.fontWeight = 'bold'; wilsonElement.style.marginBottom = '20px'; wilsonElement.style.textAlign = 'center'; container.appendChild(wilsonElement); const chartInstance = createWilsonGraph(wilsonIndices, container, athleteInfo); createComparisonUI(container, async (friendIndices, friendInfo) => { updateChart(chartInstance, friendIndices, friendInfo); }); h2Element.parentNode.insertBefore(container, h2Element.nextSibling); } } if (typeof module !== 'undefined' && module.exports) { module.exports = { calculateWilsonIndex, calculateWilsonIndexOverTime, extractEventDetails, findResultsTable, }; } else { displayWilsonIndex(); } })();