Hulu.jp Subtitle Downloader

Download subtitle guides from Hulu.jp in VTT format

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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