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