您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a countdown timer to upcoming space launches on NextSpaceflight.com (uses MutationObserver and fetches detail pages for reliable timestamps)
// ==UserScript== // @name Next Spaceflight Countdown // @description Adds a countdown timer to upcoming space launches on NextSpaceflight.com (uses MutationObserver and fetches detail pages for reliable timestamps) // @match https://nextspaceflight.com/* // @version 0.0.1.20251012082016 // @namespace https://greasyfork.org/users/1435046 // ==/UserScript== (function() { 'use strict'; // Map of month names to month indices (for parsing textual dates) const monthNameToIndex = { January: 0, February: 1, March: 2, April: 3, May: 4, June: 5, July: 6, August: 7, September: 8, October: 9, November: 10, December: 11 }; // Class name for inserted countdown elements const countdownElementClassName = 'nsf-countdown'; // Mark processed anchors to avoid double-processing const processedDataAttributeName = 'data-nsf-countdown-processed'; // Insert a countdown DOM node into the provided containerElement and return that node function createAndAttachCountdownNode(containerElement) { const countdownElement = document.createElement('div'); countdownElement.className = countdownElementClassName; countdownElement.style.marginTop = '6px'; countdownElement.style.padding = '4px 6px'; countdownElement.style.borderRadius = '6px'; countdownElement.style.fontSize = '13px'; countdownElement.style.background = 'rgba(0,0,0,0.35)'; countdownElement.style.color = 'rgb(209, 205, 199)'; countdownElement.style.display = 'inline-block'; countdownElement.textContent = 'Loading countdown...'; containerElement.appendChild(countdownElement); return countdownElement; } // Given a detail page HTML string, extract an approximate UTC/GMT launch timestamp in milliseconds. // Strategy: look for a time (HH:MM or HH:MM:SS) followed nearby by a textual date "Month Day, Year". function extractLaunchTimestampFromDetailHtml(detailHtmlText) { const textContent = detailHtmlText.replace(/\r/g, ' ').replace(/\n/g, ' '); // Regex: time (HH:MM or HH:MM:SS) const timeRegex = /([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?/; // Regex: textual date like "January 15, 2025" or "October 16, 01:30" (we want Month Day, Year) const dateRegex = /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+([1-9]|[12]\d|3[01]),\s*(\d{4})\b/; const timeMatch = textContent.match(timeRegex); const dateMatch = textContent.match(dateRegex); // If the details page explicitly labels "Liftoff Time (GMT)" the time/date found are in GMT/UTC; use UTC. if (!timeMatch || !dateMatch) { return null; } const hour = parseInt(timeMatch[1], 10); const minute = parseInt(timeMatch[2], 10); const second = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0; const monthName = dateMatch[1]; const dayNumber = parseInt(dateMatch[2], 10); const yearNumber = parseInt(dateMatch[3], 10); const monthIndex = monthNameToIndex[monthName]; // Construct a UTC timestamp because the details page shows liftoff in GMT. const timestampMilliseconds = Date.UTC(yearNumber, monthIndex, dayNumber, hour, minute, second); return timestampMilliseconds; } // Given a timestamp in ms, format T-/T+ string with days/hours/minutes/seconds function formatTimeDeltaToCountdownText(launchTimestampMilliseconds) { const nowMilliseconds = Date.now(); const diffMilliseconds = launchTimestampMilliseconds - nowMilliseconds; const prefix = diffMilliseconds < 0 ? 'T+' : 'T-'; const absoluteDiff = Math.abs(diffMilliseconds); const secondsTotal = Math.floor(absoluteDiff / 1000); const days = Math.floor(secondsTotal / (24 * 3600)); const hours = Math.floor((secondsTotal % (24 * 3600)) / 3600); const minutes = Math.floor((secondsTotal % 3600) / 60); const seconds = secondsTotal % 60; return `${prefix}${days}d ${hours}h ${minutes}m ${seconds}s`; } // Update all countdown elements on the page (single centralized updater) function updateAllCountdownNodes() { const countdownNodes = document.querySelectorAll('.' + countdownElementClassName); countdownNodes.forEach(node => { const launchTimestampString = node.getAttribute('data-launch-timestamp-ms'); if (!launchTimestampString) return; const launchTimestampMilliseconds = parseInt(launchTimestampString, 10); const countdownText = formatTimeDeltaToCountdownText(launchTimestampMilliseconds); node.textContent = countdownText; }); } // Process a single anchor element pointing to a launch details page function processLaunchAnchorElement(anchorElement) { // Only process launch detail anchors const href = anchorElement.getAttribute('href') || anchorElement.href; if (!href || !href.includes('/launches/details/')) return; // Avoid re-processing if (anchorElement.hasAttribute(processedDataAttributeName)) return; anchorElement.setAttribute(processedDataAttributeName, 'true'); // Skip if this post already includes a countdown timer // Detection heuristic: look for any element that includes the text HOURS, MINS, or SECS inside this anchor const hasBuiltInCountdown = !!anchorElement.querySelector('p, div, span'); if (hasBuiltInCountdown) { const textContent = anchorElement.textContent.toUpperCase(); if (textContent.includes('HOURS') || textContent.includes('MINS') || textContent.includes('SECS')) { return; // skip adding custom countdown } } // Decide where to put the countdown visually: prefer a container inside the anchor that likely holds the metadata let insertionContainer = anchorElement.querySelector('div[class*="p-4"], div[class*="gap-2"], div[class*="pb-4"]'); if (!insertionContainer) insertionContainer = anchorElement; // create node and attach const countdownNode = createAndAttachCountdownNode(insertionContainer); // Fetch the details page and attempt to extract a reliable timestamp const resolvedUrl = anchorElement.href; fetch(resolvedUrl, { method: 'GET', credentials: 'same-origin' }) .then(response => response.text()) .then(detailHtmlText => { const launchTimestampMilliseconds = extractLaunchTimestampFromDetailHtml(detailHtmlText); if (!launchTimestampMilliseconds) { countdownNode.textContent = 'Countdown unavailable'; return; } countdownNode.setAttribute('data-launch-timestamp-ms', String(launchTimestampMilliseconds)); const formattedText = formatTimeDeltaToCountdownText(launchTimestampMilliseconds); countdownNode.textContent = formattedText; }); } // Find all existing launch anchor elements and process them function processAllExistingLaunchAnchors() { // Select anchors that link to launch details on the site const candidateAnchors = Array.from(document.querySelectorAll('a[href*="/launches/details/"]')); candidateAnchors.forEach(anchor => processLaunchAnchorElement(anchor)); } // Observe the DOM for added nodes that may contain new launch anchors (event-driven) const mutationObserver = new MutationObserver(mutationRecords => { mutationRecords.forEach(record => { // Look for newly added nodes (subtree additions) and process any anchor descendants record.addedNodes.forEach(addedNode => { if (!(addedNode instanceof Element)) return; // If the added node itself is a launch anchor if (addedNode.matches && addedNode.matches('a[href*="/launches/details/"]')) { processLaunchAnchorElement(addedNode); } // Or if the added node contains launch anchors deeper in the subtree const nestedLaunchAnchors = addedNode.querySelectorAll ? addedNode.querySelectorAll('a[href*="/launches/details/"]') : []; nestedLaunchAnchors.forEach(nestedAnchor => processLaunchAnchorElement(nestedAnchor)); }); }); }); // Start observing document body for additions mutationObserver.observe(document.body, { childList: true, subtree: true }); // Initial pass for anchors already present on load processAllExistingLaunchAnchors(); // Single shared timer that updates visible countdowns once a second // Using a single timer minimizes overhead and satisfies "single centralized updater" style // Immediately update countdowns updateAllCountdownNodes(); // Function to synchronize updates with system time function scheduleSynchronizedUpdate() { // Run the update updateAllCountdownNodes(); // Compute delay until the next full second boundary const nowMilliseconds = Date.now(); const millisecondsUntilNextSecond = 1000 - (nowMilliseconds % 1000); // Schedule the next synchronized update setTimeout(scheduleSynchronizedUpdate, millisecondsUntilNextSecond); } // Align countdown updates precisely with system time scheduleSynchronizedUpdate(); })();