NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name JW.ORG Subtitles Downloader // @namespace https://openuserjs.org/users/42 // @version 0.4 // @description Extract VTT subtitles from JW videos and convert to plain text files. // @include https://www.jw.org/* // @grant none // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0.txt // ==/UserScript== (function () { "use strict"; function filterVTT(vttContent) { const timelineRegex = /^\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}.*$/; return vttContent .split("\n") .slice(1) .filter((line) => { line = line.trim(); return line !== "" && !timelineRegex.test(line); }) .map((line) => line.trim().replace(/\.\.\.$/, "")) .join(" ") .replace(/\s+/g, " ") .trim(); } function createDownloadButton(subtitleContent, filename) { const containerElement = document.createElement("div"); containerElement.className = "subtitleDownloadButton"; containerElement.innerHTML = ` <button type="button"> <span class="secondaryButton articleShareButton"> <span class="buttonIcon" aria-hidden="true"></span> <span class="buttonText">Extract subtitles</span> </span> </button> `; containerElement .querySelector("button") .addEventListener("click", function () { const blob = new Blob([subtitleContent], { type: "text/plain; charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }); return containerElement; } function processVTTContent(vttContent, vttUrl) { const filteredContent = filterVTT(vttContent); const title = document.querySelector("h1.mediaItemTitle")?.textContent || "Untitled"; const url = location.href; console.log(`Processing subtitles for ${title} - ${url}`); return { content: `${title}\n${url}\n\n${filteredContent}`, filename: vttUrl.split("/").pop().replace(".vtt", "_subtitles.txt"), }; } function addOrUpdateDownloadButton(subtitleContent, filename) { const shareContainer = document.querySelector( "div.jsShareButtonContainer.shareButtonWrapper", ); if (shareContainer) { const downloadButton = createDownloadButton(subtitleContent, filename); const existingButton = document.querySelector(".subtitleDownloadButton"); if (existingButton) { existingButton.replaceWith(downloadButton); } else { shareContainer.insertAdjacentElement("afterend", downloadButton); } } else { console.error("no jsShareButtonContainer found"); } } let currentSubtitles; async function fetchSubtitles(url) { if (currentSubtitles === url) { return; } currentSubtitles = url; try { const response = await fetch(url); const vttContent = await response.text(); const { content, filename } = processVTTContent(vttContent, url); addOrUpdateDownloadButton(content, filename); } catch (error) { console.error("error fetching or processing VTT:", error); } } const mediaLocations = new Map(); function parseMediaLocations(data) { if (data.media && Array.isArray(data.media)) { for (const mediaItem of data.media) { if (mediaItem.files && Array.isArray(mediaItem.files)) { for (const file of mediaItem.files) { if (file.subtitles && file.subtitles.url) { mediaLocations.set( file.progressiveDownloadURL, file.subtitles.url, ); } } } } } } function addStyles() { const style = document.createElement("style"); style.textContent = ` .subtitleDownloadButton { margin-left: 15px; } .subtitleDownloadButton .buttonIcon { font-family: "media-player"; speak: none; font-size: 24px; font-style: normal; font-weight: normal; font-variant: normal; text-transform: none; line-height: 1.2; } .subtitleDownloadButton .buttonIcon::before { content: "\\e60f"; } `; document.head.appendChild(style); } addStyles(); const originalXHRSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (...args) { this.addEventListener("load", async function () { if (this.responseURL.match(/\/v1\/media-items\//)) { try { parseMediaLocations(JSON.parse(this.responseText)); } catch (error) { console.error("error processing media-items API response:", error); } } }); originalXHRSend.apply(this, args); }; const mainElement = document.querySelector("main"); const observer = new MutationObserver((mutations) => { const candidates = new Set(); for (const mutation of mutations) { for (const addedNode of mutation.addedNodes) { if (addedNode.nodeType !== Node.ELEMENT_NODE) { continue; } for (const link of addedNode.querySelectorAll( ".jsItemContainer .jsDownloadButtonContainer.fileTypeButtons a", )) { const subtitlesLocation = mediaLocations.get(link.href); if (subtitlesLocation) { candidates.add(subtitlesLocation); } } } } console.log("found possible subtitles ", candidates.size); for (const subtitlesLocation of candidates) { fetchSubtitles(subtitlesLocation); break; } }); if (mainElement) { observer.observe(mainElement, { subtree: true, childList: true, }); } else { console.error("main element not found"); } })();