Next Spaceflight Countdown

Adds a countdown timer to upcoming space launches on NextSpaceflight.com (uses MutationObserver and fetches detail pages for reliable timestamps)

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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


})();