Hulu.jp Subtitle Downloader

Download subtitle guides from Hulu.jp in VTT format

// ==UserScript==
// @name         Hulu.jp Subtitle Downloader
// @namespace    https://hulu.jp
// @version      2.0.0
// @description  Download subtitle guides from Hulu.jp in VTT format
// @author       Ronny
// @match        https://*.hulu.jp/watch/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @license      MIT
// ==/UserScript==

(function () {
  ("use strict");

  // Functions
  const createButton = (text, marginInlineStart = "0") => {
    const btn = document.createElement("button");
    btn.innerHTML = text;
    btn.style.border = "none";
    btn.style.borderRadius = "4px";
    btn.style.backgroundColor = "#889188";
    btn.style.color = "#fefffe";
    btn.style.padding = "1em 1.5em";
    btn.style.lineHeight = "1";
    btn.style.fontSize = "0.8em";
    btn.style.marginInlineStart = marginInlineStart;
    btn.addEventListener("mouseover", () => (btn.style.opacity = "0.8"));
    btn.addEventListener("mouseout", () => (btn.style.opacity = "1"));
    return btn;
  };

  const appendButton = (button, parent) => {
    if (!parent.contains(button)) {
      parent.appendChild(button);
    }
  };

  const button = createButton("字幕ガイドをダウンロード");
  const batchButton = createButton(
    "最終話までの字幕ガイドをダウンロード",
    "1em"
  );

  let subSrc = "";

  const downloadSubtitle = (subSrc, fileName) => {
    if (!subSrc) {
      console.error("No subtitle track found.");
      return;
    }

    fetch(subSrc)
      .then((response) => response.text())
      .then((data) => {
        const blob = new Blob([data], { type: "text/vtt" });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");

        a.href = url;
        a.download = fileName;
        a.click();
        URL.revokeObjectURL(url);
      })
      .catch((error) => {
        console.error("Failed to download subtitle:", error);
      });
  };
  button.onclick = () => {
    const pageTitle = document.title;
    const fileName = pageTitle.split(" | ")[0].trim() + ".vtt";
    downloadSubtitle(subSrc, fileName);
  };

  const startBatchDownload = () => {
    if (
      window.confirm(
        `下载过程中请在播放器上滑动鼠标以激活下一集按钮。
      While downloading, please hover over the player to activate the next episode button.
      ダウンロード中は、プレーヤーにマウスを重ねて次のエピソードボタンをアクティブにしてください。`
      )
    ) {
      const subSrcList = [];
      // For the current episode
      pushSubtitleSrc(subSrcList, subSrc);
      // Fetch next episode
      fetchNextEpisode(subSrcList);
    }
  };
  batchButton.onclick = startBatchDownload;

  const fetchNextEpisode = (subSrcList) => {
    const nextButton = document.querySelector('[aria-label="次の動画"]');
    if (nextButton && !nextButton.classList.contains("disabled")) {
      setTimeout(() => {
        pushSubtitleSrc(subSrcList, subSrc);
        fetchNextEpisode(subSrcList);
      }, 5000);
      nextButton.click();
    } else {
      setTimeout(() => {
        downloadAllSubtitles(subSrcList);
      }, 5000);
    }
  };

  const pushSubtitleSrc = (subSrcList, subSrc) => {
    if (subSrc) {
      const pageTitle = document.title;
      const fileName = pageTitle.split(" | ")[0].trim() + ".vtt";
      subSrcList.push({ src: subSrc, name: fileName });
      console.log("Added subtitle track for", fileName);
    }
  };

  const downloadAllSubtitles = (subSrcList) => {
    if (subSrcList.length === 0) {
      console.error("No subtitle tracks found.");
      return;
    }

    const zip = new JSZip();
    let count = 0;

    subSrcList.forEach(({ src, name }) => {
      fetch(src)
        .then((response) => response.text())
        .then((data) => {
          zip.file(`${name}`, data);
          count++;
          if (count === subSrcList.length) {
            zip.generateAsync({ type: "blob" }).then((content) => {
              const pageTitle = document.title;
              const batchName = pageTitle.split("第")[0].trim();
              const url = URL.createObjectURL(content);
              const a = document.createElement("a");
              a.href = url;
              a.download = `${batchName}.zip`;
              a.click();
              URL.revokeObjectURL(url);
            });
          }
        })
        .catch((error) => {
          console.error("Failed to download subtitle:", error);
        });
    });
  };

  // Override XMLHttpRequest open method
  const handleXhrLoad = (response) => {
    try {
      const targetTrack = response.tracks.find(
        (track) => track.label === "字幕ガイド"
      );

      if (targetTrack) {
        const src = targetTrack.src;
        console.log("Found subtitle track, src:", src);
        subSrc = src;
        // Create buttons
        const titlePanel =
          document.getElementsByClassName("watch-info-title")[0];
        appendButton(button, titlePanel);
        appendButton(batchButton, titlePanel);
      } else {
        console.log("No subtitle track found.");
      }
    } catch (e) {
      console.error("Failed to parse JSON response:", e);
    }
  };
  const originalXhrOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this.addEventListener("load", function () {
      if (url.startsWith("https://playback.prod.hjholdings.tv/session/open")) {
        handleXhrLoad(this.response);
      }
    });
    originalXhrOpen.apply(this, [method, url, ...rest]);
  };
})();