您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
YouTube Helper API.
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.greasyfork.org/scripts/549881/1664588/YouTube%20Helper%20API.js
// ==UserScript== // @name YouTube Helper API // @author ElectroKnight22 // @namespace electroknight22_helper_api_namespace // @version 0.2.0 // @license MIT // @description YouTube Helper API. // ==/UserScript== /*jshint esversion: 11 */ /** * @namespace youtubeHelperApi * @description A comprehensive helper API for interacting with the YouTube player and page. * It provides a stable interface for accessing player state, video data, and controlling playback. */ window.youtubeHelperApi = (function () { ('use strict'); /** * @property {Object} SELECTORS - A centralized object for all CSS selectors used in the script. * @private */ const SELECTORS = { pageManager: 'ytd-page-manager', shortsPlayer: '#shorts-player', watchPlayer: '#movie_player', inlinePlayer: '.inline-preview-player', videoElement: 'video', watchFlexy: 'ytd-watch-flexy', chatFrame: 'ytd-live-chat-frame#chat', chatContainer: '#chat-container', }; /** * @property {Object} POSSIBLE_RESOLUTIONS - A manually updated map of all the possible YouTube's resolution options. * @public */ const POSSIBLE_RESOLUTIONS = Object.freeze({ highres: { p: 4320, label: '8K' }, hd2160: { p: 2160, label: '4K' }, hd1440: { p: 1440, label: '1440p' }, hd1080: { p: 1080, label: '1080p' }, hd720: { p: 720, label: '720p' }, large: { p: 480, label: '480p' }, medium: { p: 360, label: '360p' }, small: { p: 240, label: '240p' }, tiny: { p: 144, label: '144p' }, }); /** * @property {Proxy} apiProxy - A safe proxy to interact with YouTube's internal player API. * Prevents errors when the API or its methods are not available. * @private */ const apiProxy = new Proxy( {}, { get(target, property) { return (...args) => { if (player.api && typeof player.api[property] === 'function') { return player.api[property](...args); } }; }, }, ); /** * @property {Object} player - Internal state object for the YouTube player. * @property {Object|null} player.playerObject - The root HTML element of the player. * @property {Object|null} player.api - The internal YouTube player API object. * @property {HTMLVideoElement|null} player.videoElement - The <video> element. * @property {boolean} player.isFullscreen - Whether the player is in fullscreen mode. * @property {boolean} player.isTheater - Whether the player is in theater mode. * @private */ const player = { playerObject: null, api: null, videoElement: null, isFullscreen: false, isTheater: false, }; /** * @property {Object} video - Internal state object for the current video's data. * @private */ const video = { id: '', title: '', channel: '', channelId: '', rawDescription: '', rawUploadDate: '', rawPublishDate: '', uploadDate: null, publishDate: null, lengthSeconds: 0, viewCount: 0, likeCount: 0, isLive: false, isFamilySafe: false, thumbnails: [], playingLanguage: null, originalLanguage: null, }; /** * @property {Object} chat - Internal state object for the live chat component. * @private */ const chat = { container: null, iFrame: null, isCollapsed: false, }; /** * @property {Object} page - Internal state object for the current YouTube page. * @private */ const page = { manager: document.querySelector(SELECTORS.pageManager), watchFlexy: document.querySelector(SELECTORS.watchFlexy), isIframe: window.top !== window.self, isMobile: window.location.hostname === 'm.youtube.com', type: 'unknown', }; let detectedAds = false; /** * @description Finds the current YouTube player element on the page as a fallback. * @returns {HTMLElement|null} The player element or null if not found. * @private */ function fallbackGetPlayerApi() { const pathname = window.location.pathname; if (pathname.startsWith('/shorts')) return document.querySelector(SELECTORS.shortsPlayer); if (pathname.startsWith('/watch')) return document.querySelector(SELECTORS.watchPlayer); return document.querySelector(SELECTORS.inlinePlayer); } /** * @description Saves data to localStorage. * @param {string} storageKey - The key to save the data under. * @param {*} data - The data to be saved (will be JSON stringified). */ function saveToStorage(storageKey, data) { try { localStorage.setItem(storageKey, JSON.stringify(data)); } catch (error) { console.error(`Error saving data for key "${storageKey}":`, error); } } /** * @description Loads data from localStorage, merging with default data. * @param {string} storageKey - The key to load data from. * @param {Object} defaultData - Default data to return if loading fails or key doesn't exist. * @returns {Object} The loaded and merged data. */ function loadFromStorage(storageKey, defaultData) { try { const storedData = JSON.parse(localStorage.getItem(storageKey)); return { ...defaultData, ...storedData }; } catch (error) { console.error(`Error loading data for key "${storageKey}":`, error); return defaultData; } } /** * @description Loads data from localStorage and cleans it, ensuring only keys present in defaultData are kept. * @param {string} storageKey - The key to load data from. * @param {Object} defaultData - The object defining the desired shape of the loaded data. * @returns {Object} The loaded and cleaned data. */ function loadAndCleanFromStorage(storageKey, defaultData) { try { const storedData = JSON.parse(localStorage.getItem(storageKey)) || {}; const combinedData = { ...defaultData, ...storedData }; const cleanedData = Object.keys(defaultData).reduce((accumulator, currentKey) => { accumulator[currentKey] = combinedData[currentKey]; return accumulator; }, {}); return cleanedData; } catch (error) { console.error(`Error loading and cleaning data for key "${storageKey}":`, error); return defaultData; } } /** * @description Calculates the best playable quality based on a target resolution string and the actual available resolutions. * @param {string} targetResolutionString - The desired quality (e.g., 'hd1080', 'large'). * @param {boolean} [usePremium=true] - Whether to use premium bitrate versions if available. * @returns {Object|null} The optimal quality data object from the player API, or null. */ function getOptimalResolution(targetResolutionString, usePremium = true) { const PREMIUM_INDICATOR = 'Premium'; try { if (!targetResolutionString || !POSSIBLE_RESOLUTIONS[targetResolutionString]) throw new Error(`Invalid target resolution: ${targetResolutionString}`); const videoQualityData = apiProxy.getAvailableQualityData(); const availableQualities = [...new Set(videoQualityData.map((q) => q.quality))]; const targetValue = POSSIBLE_RESOLUTIONS[targetResolutionString].p; const bestQualityString = availableQualities .filter((q) => POSSIBLE_RESOLUTIONS[q] && POSSIBLE_RESOLUTIONS[q].p <= targetValue) .sort((a, b) => POSSIBLE_RESOLUTIONS[b].p - POSSIBLE_RESOLUTIONS[a].p)[0]; if (!bestQualityString) return null; let normalCandidate = null; let premiumCandidate = null; for (const quality of videoQualityData) { if (quality.quality === bestQualityString && quality.isPlayable) { if (usePremium && quality.qualityLabel?.trim().endsWith(PREMIUM_INDICATOR)) premiumCandidate = quality; else normalCandidate = quality; } } return premiumCandidate || normalCandidate; } catch (error) { console.error('Error when resolving optimal quality:', error); return null; } } /** * @description Sets the video playback quality. * @param {string} targetResolution - The desired quality string (e.g., 'hd1080'). * @param {boolean} [ignoreAvailable=false] - If true, forces the quality without checking availability. * @param {boolean} [usePremium=true] - Whether to use premium bitrate versions if available. */ function setPlaybackResolution(targetResolution, ignoreAvailable = false, usePremium = true) { try { if (!player.api?.getAvailableQualityData) return; if (!usePremium && ignoreAvailable) { apiProxy.setPlaybackQualityRange(targetResolution); } else { const optimalQuality = getOptimalResolution(targetResolution, usePremium); if (optimalQuality) apiProxy.setPlaybackQualityRange( optimalQuality.quality, optimalQuality.quality, usePremium ? optimalQuality.formatId : null, ); } } catch (error) { console.error('Error when setting resolution:', error); } } /** * @description Dispatches a custom event to signal that the API is ready and has updated player data. * @private */ function dispatchHelperApiReadyEvent() { if (!player.api) return; const event = new CustomEvent('yt-helper-api-ready', { detail: Object.freeze({ ...publicApi }) }); document.dispatchEvent(event); } /** * @description Updates the video language states based on available and playing audio tracks. * @private */ function updateVideoLanguage() { if (!player.api) return; const getAudioTrackId = (track) => Object.values(track ?? {}).find((p) => p?.id)?.id ?? null; const availableTracks = apiProxy.getAvailableAudioTracks(); if (availableTracks.length === 0) return; const renderer = apiProxy.getPlayerResponse()?.captions?.playerCaptionsTracklistRenderer; const originalAudioId = renderer?.audioTracks?.[renderer?.defaultAudioTrackIndex]?.audioTrackId; const playingAudioTrack = apiProxy.getAudioTrack(); const originalAudioTrack = availableTracks.find((track) => getAudioTrackId(track) === originalAudioId); video.playingLanguage = playingAudioTrack; video.originalLanguage = originalAudioTrack; } /** * @description Updates the internal video state object with the latest data from the player response. * @private */ function updateVideoState() { if (!player.api) return; const playerResponseObject = apiProxy.getPlayerResponse(); video.id = playerResponseObject?.videoDetails?.videoId; video.title = playerResponseObject?.videoDetails?.title; video.channel = playerResponseObject?.videoDetails?.author; video.channelId = playerResponseObject?.videoDetails?.channelId; video.rawDescription = playerResponseObject?.videoDetails?.shortDescription; video.rawUploadDate = playerResponseObject?.microformat?.playerMicroformatRenderer?.uploadDate; video.rawPublishDate = playerResponseObject?.microformat?.playerMicroformatRenderer?.publishDate; video.uploadDate = video.rawUploadDate ? new Date(video.rawUploadDate) : null; video.publishDate = video.rawPublishDate ? new Date(video.rawPublishDate) : null; video.lengthSeconds = parseInt(playerResponseObject?.videoDetails?.lengthSeconds ?? '0', 10); video.viewCount = parseInt(playerResponseObject?.videoDetails?.viewCount ?? '0', 10); video.likeCount = parseInt(playerResponseObject?.microformat?.playerMicroformatRenderer?.likeCount ?? '0', 10); video.isLive = playerResponseObject?.videoDetails?.isLiveContent; video.isFamilySafe = playerResponseObject?.microformat?.playerMicroformatRenderer?.isFamilySafe; video.thumbnails = playerResponseObject?.microformat?.playerMicroformatRenderer?.thumbnail?.thumbnails; } /** * @description Updates the internal player state object. * @param {Event|null} event - The event that triggered the update (e.g., 'yt-player-updated'). * @private */ function updatePlayerState(event) { player.api = event?.target?.player_ ?? fallbackGetPlayerApi(); player.playerObject = event?.target?.playerContainer_?.children[0] ?? fallbackGetPlayerApi(); player.videoElement = player.playerObject?.querySelector(SELECTORS.videoElement); } /** * @description Updates fullscreen state based on the document's fullscreen element. * @private */ function updateFullscreenState() { player.isFullscreen = !!document.fullscreenElement; } /** * @description Updates theater mode state from a custom YouTube event. * @param {CustomEvent} event - The 'yt-set-theater-mode-enabled' event. * @private */ function updateTheaterState(event) { player.isTheater = !!event?.detail?.enabled; } /** * @description Updates the chat state from a custom YouTube event. * @param {CustomEvent} event - The 'yt-chat-collapsed-changed' event. * @private */ function updateChatState(event) { chat.iFrame = event?.target ?? document.querySelector(SELECTORS.chatFrame); chat.container = chat.iFrame?.parentElement ?? document.querySelector(SELECTORS.chatContainer); chat.isCollapsed = event?.detail ?? true; document.dispatchEvent(new CustomEvent('yt-helper-api-chat-state-updated', { detail: Object.freeze({ ...chat }) })); } /** * @description Checks if the script is running inside an iframe and dispatches an event if so. * @private */ function checkIsIframe() { if (page.isIframe) document.dispatchEvent(new Event('yt-helper-api-detected-iframe')); } /** * @description Checks for ad presence by inspecting CSS classes on the player container. * @returns {boolean|undefined} True if ad is present, otherwise false. */ function checkAdPresense() { try { const shouldAvoid = player.container.classList.contains('unstarted-mode'); // YouTube doesn't update ad state fully until player is marked as started. const isAdPresent = player.container.classList.contains('ad-showing') || player.container.classList.contains('ad-interrupting'); return !shouldAvoid && isAdPresent; } catch (error) { console.error('Error in checkAdState:', error); return false; } } /** * @description A fallback method to check for ads by comparing video duration from different API sources. * @returns {boolean} True if an ad is detected. */ function fallbackCheckAdPresense() { if (!player.api) return; try { const progressState = apiProxy.getProgressState(); const reportedContentDuration = progressState.duration; const realContentDuration = apiProxy.getDuration() ?? -1; const durationMismatch = Math.trunc(realContentDuration) !== Math.trunc(reportedContentDuration); const hasAds = durationMismatch; if (hasAds) document.dispatchEvent(new CustomEvent('yt-helper-api-ad-detected')); if (hasAds !== detectedAds) document.dispatchEvent(new CustomEvent('yt-helper-api-ad-state-changed', { detail: Object.freeze({ adState: hasAds }) })); detectedAds = hasAds; return detectedAds; } catch (error) { console.error('Error during ad check:', error); return false; } } /** * @description Main handler for player updates. Triggers all necessary state update functions. * @param {Event|null} [event=null] - The event that triggered the update. * @private */ function handlePlayerUpdate(event = null) { updatePlayerState(event); updateVideoState(); updateVideoLanguage(); fallbackCheckAdPresense(); dispatchHelperApiReadyEvent(); } /** * @description Handler for the 'yt-navigate-finish' event. Updates page state. * @param {CustomEvent} event - The navigation event. * @private */ function handleNavigationFinish(event) { page.type = event?.detail?.pageType; page.manager = document.querySelector(SELECTORS.pageManager); page.watchFlexy = document.querySelector(SELECTORS.watchFlexy); } /** * @description Adds event listeners related to the player state. * @private */ function addPlayerStateListeners() { const PLAYER_UPDATE_EVENT = page.isMobile ? 'state-navigateend' : 'yt-player-updated'; document.addEventListener(PLAYER_UPDATE_EVENT, handlePlayerUpdate); document.addEventListener('fullscreenchange', updateFullscreenState); document.addEventListener('yt-set-theater-mode-enabled', updateTheaterState); } /** * @description Adds event listeners related to the chat state. * @private */ function addChatStateListeners() { document.addEventListener('yt-chat-collapsed-changed', updateChatState); } /** * @description Adds event listeners related to page navigation. * @private */ function addNavigationListeners() { document.addEventListener('yt-navigate-finish', handleNavigationFinish); } /** * @description Initializes the script by setting up all event listeners. * @private */ function initialize() { window.addEventListener('pageshow', handlePlayerUpdate); checkIsIframe(); addNavigationListeners(); addPlayerStateListeners(); addChatStateListeners(); } initialize(); const publicApi = { /** @type {Object} A read-only copy of the player state object. */ get player() { return { ...player }; }, /** @type {Object} A read-only copy of the video state object. */ get video() { return { ...video }; }, /** @type {Object} A read-only copy of the chat state object. */ get chat() { return { ...chat }; }, /** @type {Object} A read-only copy of the page state object. */ get page() { return { ...page }; }, /** @type {boolean} A read-only flag indicating if an ad has been detected. */ get isAdPlaying() { return detectedAds; }, POSSIBLE_RESOLUTIONS, checkAdPresense, fallbackCheckAdPresense, getOptimalResolution, setPlaybackResolution, saveToStorage, loadFromStorage, loadAndCleanFromStorage, apiProxy, }; return publicApi; })();