42 / JW.ORG Subtitles Downloader

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