YouTube Transcript for all videos with subs

Shows a transcript with clickable timestamps for any video with subtitles. Just enable subtitles and look in the description (or the dev console).

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         YouTube Transcript for all videos with subs
// @namespace    https://baegus.cz
// @version      0.1
// @description  Shows a transcript with clickable timestamps for any video with subtitles. Just enable subtitles and look in the description (or the dev console).
// @author       Jaroslav Petrnoušek
// @match        https://www.youtube.com/*
// @match        https://youtube.com/*
// @license      MIT
// @grant        none
// ==/UserScript==

(function() {
	'use strict';

	function seekToTime (milliseconds) {
		window.scrollTo(0,0);
		const player = document.querySelector("video");
		if (player) {
			player.currentTime = milliseconds / 1000;
		}
	}

	const pad = (num) => num.toString().padStart(2, "0");

	function formatYouTubeTime(ms) {
		const totalSeconds = Math.floor(ms / 1000);
		const days = Math.floor(totalSeconds / (24 * 3600));
		const remainingSeconds = totalSeconds % (24 * 3600);

		const hours = Math.floor(remainingSeconds / 3600);
		const minutes = Math.floor((remainingSeconds % 3600) / 60);
		const seconds = remainingSeconds % 60;

		// Construct the time string based on duration
		if (days > 0) {
			return `${days}:${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
		} else if (hours > 0) {
			return `${hours}:${pad(minutes)}:${pad(seconds)}`;
		} else {
			return `${minutes}:${pad(seconds)}`;
		}
	}

	function dispatchSubtitleData(subtitleData) {
		const event = new CustomEvent("youtubeSubtitleData", {
			detail: subtitleData
		});
		window.dispatchEvent(event);
	}

	// Intercept Fetch requests
	const originalFetch = window.fetch;
	window.fetch = async function(...args) {
		try {
			const response = await originalFetch(...args);

			if (args[0] && typeof args[0] === "string" && args[0].includes("/api/timedtext")) {
				const clonedResponse = response.clone();
				try {
					const data = await clonedResponse.json();
					dispatchSubtitleData({
						type: "fetch",
						url: args[0],
						data: data
					});
				} catch (jsonError) {
					console.log("Error parsing subtitle JSON (fetch):", jsonError);
				}
			}
			return response;
		} catch (fetchError) {
			throw fetchError;
		}
	};

	// Intercept XMLHttpRequest
	const originalXHROpen = XMLHttpRequest.prototype.open;
	const originalXHRSend = XMLHttpRequest.prototype.send;

	XMLHttpRequest.prototype.open = function(...args) {
		this._url = args[1];
		return originalXHROpen.apply(this, args);
	};

	XMLHttpRequest.prototype.send = function(...args) {
		const xhr = this;

		if (xhr._url && xhr._url.includes("/api/timedtext")) {
			const originalOnLoad = xhr.onload;
			xhr.onload = function() {
				try {
					const data = JSON.parse(xhr.responseText);

					dispatchSubtitleData({
						type: "xhr",
						url: xhr._url,
						data: data
					});
				} catch (parseError) {
					console.log("Error parsing subtitle JSON (XHR):", parseError);
				}

				if (originalOnLoad) {
					originalOnLoad.apply(xhr, arguments);
				}
			};

			const originalAddEventListener = xhr.addEventListener;
			xhr.addEventListener = function(event, callback, ...rest) {
				if (event === "load") {
					const wrappedCallback = function() {
						try {
							const data = JSON.parse(xhr.responseText);

							dispatchSubtitleData({
								type: "xhr",
								url: xhr._url,
								data: data
							});
						} catch (parseError) {
							console.log("Error parsing subtitle JSON (XHR addEventListener):", parseError);
						}

						return callback.apply(this, arguments);
					};

					return originalAddEventListener.call(xhr, event, wrappedCallback, ...rest);
				}

				return originalAddEventListener.call(xhr, event, callback, ...rest);
			};
		}

		return originalXHRSend.apply(this, args);
	};

	console.log("'YouTube Transcript for all videos' is active. Enable subtitles to show transcript here in the console!");

	window.addEventListener("youtubeSubtitleData", async function(event) {
		try {
			//console.log("Subtitle Data Intercepted:", event.detail);

			const subtitleData = event.detail.data;
			if (!event.detail.data) {
				throw new Error("No subtitle data");
			}
			const timedTextData = [];
			const timeTextLines = [];
			subtitleData.events.forEach(event => {
				if (!event.segs) return;
				const segText = event.segs.map(segData => segData.utf8).join(" ");
				if (segText == "\n") return;
				const timeFormatted = formatYouTubeTime(event.tStartMs);
				timedTextData.push({
					time: event.tStartMs,
					timeFormatted,
					text: segText,
				});
				timeTextLines.push(`${timeFormatted}  ${segText}`);
			});
			console.clear();
			console.log("Transcript created. You can find it in the video description (with clickable timestamps) or right here:")
			console.log(timeTextLines.join("\n"));

			const bottomRowItems = document.querySelector("#bottom-row #items");

			const existingTranscriptCont = bottomRowItems.querySelector("#customTranscriptCont");
			if (existingTranscriptCont) {
				existingTranscriptCont.parentNode.removeChild(existingTranscriptCont);
			}

			const transcriptCont = document.createElement("div");
			transcriptCont.id = "customTranscriptCont";

			const transcriptTitle = document.createElement("h2");
			transcriptTitle.className = "style-scope ytd-rich-list-header-renderer";
			Object.assign(transcriptTitle.style, {
				"margin": "1em 0",
			});

			transcriptTitle.innerText = "Custom transcript";
			transcriptCont.appendChild(transcriptTitle);

			for (const line of timedTextData) {
				const lineCont = document.createElement("p");

				const timestampLink = document.createElement("span");
				timestampLink.className = "yt-core-attributed-string__link yt-core-attributed-string__link--call-to-action-color";
				timestampLink.style.cursor = "pointer";
				timestampLink.innerText = `${line.timeFormatted} `;
				lineCont.appendChild(timestampLink);
				timestampLink.addEventListener("click",(e) => {
					seekToTime(line.time);
				});

				const subtitleText = document.createElement("span");
				subtitleText.innerText = line.text;
				lineCont.appendChild(subtitleText);

				transcriptCont.appendChild(lineCont);
			}

			bottomRowItems.prepend(transcriptCont);

		} catch (error) {
			console.error("Error processing subtitle data:", error);
		}
	});
})();