以绝对时间 (yyyy-mm-dd) 显示 YouTube 的视频上传日期

显示具体日期而不是“2 星期前”,“1 年前”这种

// ==UserScript==
// @name              以绝对时间 (yyyy-mm-dd) 显示 YouTube 的视频上传日期
// @name:en           Display YouTube video upload dates as absolute dates (yyyy-mm-dd)
// @version           0.5.1
// @description       显示具体日期而不是“2 星期前”,“1 年前”这种
// @description:en    Show full upload dates, instead of "1 year ago", "2 weeks ago", etc.
// @author            InMirrors
// @namespace         https://greasyfork.org/users/518374
// @match             https://www.youtube.com/*
// @icon              https://www.youtube.com/s/desktop/814d40a6/img/favicon_144x144.png
// @grant             GM_getValue
// @grant             GM_setValue
// @grant             GM_registerMenuCommand
// @license           MIT
// ==/UserScript==

(function () {
    'use strict';

    // Convert ISO date string to a localized date string
    function isoToDate(iso) {
        let date = new Date(iso);

        // change the locale to use yyyy-mm-dd format
        let options = { year: 'numeric', month: '2-digit', day: '2-digit' };
        let lang = 'zh-CN';
        return date.toLocaleDateString(lang, options).replaceAll('/', '-');
    }

    const PROCESSED_MARKER = '\u200B'; // 零宽空格 (Zero-Width Space)


    let debugModeEnabled = GM_getValue("debugModeEnabled", false);
    let debugVidsArrayLength = GM_getValue("debugVidsArrayLength", 4);
    GM_registerMenuCommand(`Toggle Debug mode ${debugModeEnabled ? "OFF" : "ON"}`, () => {
        debugModeEnabled = !debugModeEnabled;
        GM_setValue("debugModeEnabled", debugModeEnabled);
        alert(`Debug mode is now ${debugModeEnabled ? "ON" : "OFF"}`);
    });
    function setVidsArrayLength() {
        let input = prompt("Please enter a number, 0 to disable slicing:", debugVidsArrayLength);
        if (input == null) { // input canceled
            return;
        }
        if (input.trim() !== "" && !isNaN(input)) { // valid input
            debugVidsArrayLength = Number(input);
            GM_setValue("debugVidsArrayLength", debugVidsArrayLength);
            alert("Value updated to: " + debugVidsArrayLength);
        } else {
            alert("Invalid input. Please enter a number.");
        }
    }
    if (debugModeEnabled) {
        GM_registerMenuCommand("Set vids array length", setVidsArrayLength);
    }

    function getUploadDate() {
        let el = document.body.querySelector('player-microformat-renderer script');
        if (el) {
            let parts = el.textContent.split('"startDate":"', 2);
            if (parts.length == 2) {
                return parts[1].split('"', 1)[0];
            }
            parts = el.textContent.split('"uploadDate":"', 2);
            if (parts.length == 2) {
                return parts[1].split('"', 1)[0];
            }
        }

        return null;
    }

    // Check if the video is a live broadcast
    function getIsLiveBroadcast() {
        let el = document.body.querySelector('player-microformat-renderer script');
        if (!el) {
            return null;
        }

        let parts = el.textContent.split('"isLiveBroadcast":', 2);
        if (parts.length != 2) {
            return false;
        }

        let isLiveBroadcast = !!parts[1].split(',', 1)[0];
        if (!isLiveBroadcast) {
            return false;
        }

        parts = el.textContent.split('"endDate":"', 2);
        if (parts.length == 2) {
            return false;
        }

        return true;
    }

    // Extract video id from the URL
    function urlToVideoId(url) {
        let parts = url.split('/shorts/', 2);
        if (parts.length === 2) {
            url = parts[1];
        } else {
            url = parts[0];
        }

        parts = url.split('v=', 2);
        if (parts.length === 2) {
            url = parts[1];
        } else {
            url = parts[0];
        }

        return url.split('&', 1)[0];
    }

    // Retrieve the upload date from a remote source using the video id and invoke the callback with the result
    function getRemoteUploadDate(videoId, callback) {
        let body = { "context": { "client": { "clientName": "WEB", "clientVersion": "2.20240416.01.00" } }, "videoId": videoId };

        fetch('https://www.youtube.com/youtubei/v1/player?prettyPrint=false', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(body)
        })
            .then(response => {
                if (!response.ok) {
                    throw new Error('Network response was not ok');
                }

                return response.json();
            })
            .then(data => {
                let object = data.microformat.playerMicroformatRenderer;

                if (object.liveBroadcastDetails?.isLiveNow) {
                    callback(object.liveBroadcastDetails.startTimestamp);
                    return;
                } else if (object.publishDate) {
                    callback(object.publishDate);
                    return;
                }

                callback(object.uploadDate);
            })
            .catch(error => {
                console.error('There was a problem with the fetch operation:', error);
            });
    }

    /**
     * Process and update upload date info for a video element
     * @param {string} videoId - YouTube video ID
     * @param {HTMLElement} dateElem - DOM element that holds the date
     * @param {string} originalDateText - The original relative time text
     */
    function fetchAndUpdateUploadDate(videoId, dateElem, originalDateText) {
        getRemoteUploadDate(videoId, (uploadDate) => {
            const formattedDate = isoToDate(uploadDate) + PROCESSED_MARKER;
            let displayText;
            const oldUploadRegex = /days?|weeks?|months?|years?|天|日|周|週|月|年/;
            if (!oldUploadRegex.test(originalDateText)) {
                // Keep original + formatted date for recent uploads
                displayText = `${originalDateText} · ${formattedDate}`;
            } else {
                // Show only formatted date
                displayText = formattedDate;
            }
            dateElem.textContent = displayText;
        });
    }

    // Update the upload date and display style of video descriptions on the page
    function startTimers() {
        /* video page description */
        setInterval(() => {
            // Retrieve the upload date
            let uploadDate = getUploadDate();
            if (!uploadDate) {
                return;
            }

            // Format the date and check if it's a live broadcast
            uploadDate = isoToDate(uploadDate);
            let isLiveBroadcast = getIsLiveBroadcast();

            if (isLiveBroadcast) {
                document.body.classList.add('ytud-description-live');
            } else {
                document.body.classList.remove('ytud-description-live');
            }

            // Update the upload date in the video description
            let el = document.querySelector('#info-container > #info > b');
            if (!el) {
                let span = document.querySelector('#info-container > #info > span:nth-child(1)');
                if (!span) {
                    return;
                }
                el = document.createElement('b');
                el.textContent = uploadDate;
                span.insertAdjacentElement('afterend', el);
            } else {
                if (el.parentNode.children[1] !== el) {
                    let container = el.parentNode;
                    el = container.removeChild(el);
                    container.children[0].insertAdjacentElement('afterend', el);
                }
                if (el.firstChild.nodeValue === uploadDate) {
                    return;
                }
                el.firstChild.nodeValue = uploadDate;
            }
        }, 1000);

        /**
         * Finds and processes video elements on the page based on a given configuration.
         * This function queries for video containers, extracts metadata,
         * and updates the date information.
         * @param {object} config - Configuration for a specific type of video list.
         * @param {string} config.id - Identifier for the configuration.
         * @param {RegExp} config.urlPattern - A regular expression to test against the current URL.
         * @param {string} config.videoContainerSelector - CSS selector for the video container elements.
         * @param {string} config.metaSpansSelector - CSS selector for metadata spans within the video container.
         * @param {string} config.vidLinkSelector - CSS selector for the video link element.
         * @param {boolean} config.shouldCreateDateSpan - If a new date span needs to be created.
         * @param {number} config.insertAfterIndex - Index at which to insert the new date span.
         * @param {number} config.dateSpanIndex - Index of the date span in the metadata spans list.
         */
        function findAndProcessVids(config) {
            // Skip when current address does not match the pattern
            if (config.urlPattern && !config.urlPattern.test(window.location.href)) {
                return;
            }

            let vids = document.querySelectorAll(config.videoContainerSelector);
            if (vids.length === 0) {
                // if (debugModeEnabled) console.warn(`No vids found for [${config.id}]`);
                return; // No videos found for this config, just return.
            }

            // Only process some elements to avoid excessive logging in debug mode.
            if (debugModeEnabled && debugVidsArrayLength != 0 && vids.length > 1) {
                vids = Array.from(vids).slice(0, debugVidsArrayLength);
            }

            vids.forEach((vidContainer) => {
                const metaSpans = vidContainer.querySelectorAll(config.metaSpansSelector);
                if (metaSpans.length === 0) {
                    if (debugModeEnabled) console.warn(`No metaSpan found for [${config.id}]`);
                    return;
                }

                let dateSpan;
                // Check if a new date span needs to be created.
                if (config.shouldCreateDateSpan) {
                    dateSpan = document.createElement('span');
                    dateSpan.className = 'inline-metadata-item style-scope ytd-video-meta-block ytdf-date';
                    dateSpan.appendChild(document.createTextNode(''));
                    metaSpans[config.insertAfterIndex].insertAdjacentElement('afterend', dateSpan);
                } else {
                    dateSpan = metaSpans[config.dateSpanIndex];
                }

                if (!dateSpan) {
                    if (debugModeEnabled) console.warn(`dateSpan is null for [${config.id}]`);
                    return;
                }

                const dateText = dateSpan.textContent;
                if (!dateText) {
                    if (debugModeEnabled) console.warn(`dateText is null for [${config.id}]`);
                    return;
                }

                // Skip if already processed.
                if (dateText.includes(PROCESSED_MARKER)) {
                    return;
                }

                // Mark as processed by adding an invisible marker character.
                dateSpan.textContent = dateText + PROCESSED_MARKER;

                // Find the video link element to extract the video ID.
                const vidLinkElem = vidContainer.querySelector(config.vidLinkSelector);
                if (!vidLinkElem) {
                    if (debugModeEnabled) console.warn(`No vidLinkElem found for [${config.id}]`);
                    return;
                }

                const vidLink = vidLinkElem.getAttribute('href');
                if (!vidLink) {
                    if (debugModeEnabled) console.warn(`vidLink is null for [${config.id}]`);
                    return;
                }
                const videoId = urlToVideoId(vidLink);
                fetchAndUpdateUploadDate(videoId, dateSpan, dateText);
            });
        }

        // Configuration array for different video list types.
        const configs = [
            {
                id: 'Video Page Sidebar',
                urlPattern: /watch\?v=/,
                videoContainerSelector: 'yt-lockup-view-model.lockup',
                metaSpansSelector: '.yt-content-metadata-view-model__delimiter+ .yt-core-attributed-string--link-inherit-color',
                vidLinkSelector: '.yt-lockup-view-model__content-image',
                shouldCreateDateSpan: false,
                dateSpanIndex: 0,
            },
            {
                id: 'Homepage Videos',
                urlPattern: /www\.youtube\.com\/?$/,
                videoContainerSelector: 'ytd-rich-item-renderer.style-scope.ytd-rich-grid-renderer',
                metaSpansSelector: '.yt-core-attributed-string--link-inherit-color',
                vidLinkSelector: '.yt-lockup-view-model__content-image',
                shouldCreateDateSpan: false,
                dateSpanIndex: 3,
            },
            {
                id: 'Homepage Shorts',
                urlPattern: /XXXwww\.youtube\.com\/?$/, // remove XXX to enable this config
                videoContainerSelector: '',
                metaSpansSelector: '#metadata-line > span',
                vidLinkSelector: '.yt-lockup-view-model__content-image',
                shouldCreateDateSpan: true,
                insertAfterIndex: 0,
                dateSpanIndex: 1,
            },
            {
                id: 'Search List Videos',
                urlPattern: /results\?search_query=/,
                videoContainerSelector: 'ytd-video-renderer.ytd-item-section-renderer',
                metaSpansSelector: '.inline-metadata-item',
                vidLinkSelector: '#thumbnail',
                shouldCreateDateSpan: false,
                dateSpanIndex: 1,
            },
            {
                id: 'Search List Shorts',
                urlPattern: /XXXresults\?search_query=/,
                videoContainerSelector: '',
                metaSpansSelector: '#metadata-line > span',
                vidLinkSelector: '.yt-lockup-view-model__content-image',
                shouldCreateDateSpan: true,
                insertAfterIndex: 0,
                dateSpanIndex: 1,
            },
            {
                id: 'Subscriptions',
                urlPattern: /subscriptions/,
                videoContainerSelector: 'ytd-rich-grid-media.ytd-rich-item-renderer',
                metaSpansSelector: '#metadata-line > span',
                vidLinkSelector: 'h3 > a',
                shouldCreateDateSpan: false,
                dateSpanIndex: 1,
            },
            {
                id: 'Channel Videos',
                urlPattern: /www.youtube.com\/@.+\/videos/,
                videoContainerSelector: 'ytd-rich-grid-media.ytd-rich-item-renderer',
                metaSpansSelector: '#metadata-line > span',
                vidLinkSelector: 'h3 > a',
                shouldCreateDateSpan: false,
                dateSpanIndex: 1,
            },
            {
                id: 'Channel Featured Videos',
                urlPattern: /www.youtube.com\/@.+(\/featured)?/,
                videoContainerSelector: 'ytd-grid-video-renderer.yt-horizontal-list-renderer',
                metaSpansSelector: '#metadata-line > span',
                vidLinkSelector: 'a#thumbnail',
                shouldCreateDateSpan: false,
                dateSpanIndex: 1,
            },
            {
                id: 'Channel For You Videos',
                urlPattern: /www.youtube.com\/@.+\/?$/,
                videoContainerSelector: 'ytd-channel-video-player-renderer.ytd-item-section-renderer',
                metaSpansSelector: '#metadata-line > span',
                vidLinkSelector: '#title a',
                shouldCreateDateSpan: false,
                dateSpanIndex: 1,
            },
            {
                id: 'Video Playlist',
                urlPattern: /playlist\?list=/,
                videoContainerSelector: 'ytd-playlist-video-renderer.ytd-playlist-video-list-renderer',
                metaSpansSelector: 'span.yt-formatted-string',
                vidLinkSelector: 'a#thumbnail',
                shouldCreateDateSpan: false,
                dateSpanIndex: 2,
            }
        ];

        // Set up timers for each configuration.
        configs.forEach(config => {
            setInterval(() => findAndProcessVids(config), 1000);
        });

        // This section for the topic sidebar is too different and is kept separate.
        /* search list - topic in sidebar */
        setInterval(() => {
            let vids = document.querySelectorAll('#contents > ytd-universal-watch-card-renderer > #sections > ytd-watch-card-section-sequence-renderer > #lists > ytd-vertical-watch-card-list-renderer > #items > ytd-watch-card-compact-video-renderer');
            if (vids.length === 0) {
                return;
            }

            vids.forEach((el) => {
                let holders = el.querySelectorAll('div.text-wrapper > yt-formatted-string.subtitle');
                if (holders.length === 0) {
                    return;
                }

                let holder = holders[0];
                let separator = ' • ';
                let parts = holder.firstChild.nodeValue.split(separator, 2);
                if (parts.length < 2) {
                    return;
                }
                let prefix = parts[0] + separator;
                let dateText = parts[1];
                let text = el.getAttribute('date-text');

                if (text !== null && text === dateText) {
                    return;
                }

                el.setAttribute('date-text', dateText);
                let link = el.querySelector('a#thumbnail').getAttribute('href');
                let videoId = urlToVideoId(link);
                fetchAndUpdateUploadDate(videoId, holder, el, dateText);
            })
        }, 1000);
    }

    startTimers()

    let styleTag = document.createElement('style');
    let cssCode = "#info > span:nth-child(3) {display:none !important;}"
        + "#info > span:nth-child(4) {display:none !important;}"
        + "#info > b {font-weight:500 !important;margin-left:6px !important;}"
        + "#date-text {display:none !important;}"
        + ".ytud-description-live #info > span:nth-child(1) {display:none !important;}"
        + ".ytud-description-live #info > b {margin-left:0 !important;margin-right:6px !important;}";
    styleTag.textContent = cssCode;
    document.head.appendChild(styleTag);
})();