Amazon Video - subtitle downloader

Allows you to download subtitles from Amazon Video

目前为 2025-05-16 提交的版本。查看 最新版本

// ==UserScript==
// @name        Amazon Video - subtitle downloader
// @description Allows you to download subtitles from Amazon Video
// @license     MIT
// @version     2.0.0
// @namespace   tithen-firion.github.io
// @match       https://*.amazon.com/*
// @match       https://*.amazon.de/*
// @match       https://*.amazon.co.uk/*
// @match       https://*.amazon.co.jp/*
// @match       https://*.primevideo.com/*
// @grant       unsafeWindow
// @require     https://cdn.jsdelivr.net/gh/Stuk/jszip@579beb1d45c8d586d8be4411d5b2e48dea018c06/dist/jszip.min.js?version=3.1.5
// @require     https://cdn.jsdelivr.net/gh/eligrey/FileSaver.js@283f438c31776b622670be002caf1986c40ce90c/dist/FileSaver.min.js?version=2018-12-29
// ==/UserScript==

class ProgressBar {
  constructor(max) {
    this.current = 0;
    this.max = max;

    let container = document.querySelector("#userscript_progress_bars");
    if(container === null) {
      container = document.createElement("div");
      container.id = "userscript_progress_bars"
      document.body.appendChild(container)
      container.style
      container.style.position = "fixed";
      container.style.top = 0;
      container.style.left = 0;
      container.style.width = "100%";
      container.style.background = "red";
      container.style.zIndex = "99999999";
    }

    this.progressElement = document.createElement("div");
    this.progressElement.innerHTML = "Click to stop";
    this.progressElement.style.cursor = "pointer";
    this.progressElement.style.fontSize = "16px";
    this.progressElement.style.textAlign = "center";
    this.progressElement.style.width = "100%";
    this.progressElement.style.height = "20px";
    this.progressElement.style.background = "transparent";
    this.stop = new Promise(resolve => {
      this.progressElement.addEventListener("click", () => {resolve(STOP_THE_DOWNLOAD)});
    });

    container.appendChild(this.progressElement);
  }

  increment() {
    this.current += 1;
    if(this.current <= this.max) {
      let p = this.current / this.max * 100;
      this.progressElement.style.background = `linear-gradient(to right, green ${p}%, transparent ${p}%)`;
    }
  }

  destroy() {
    this.progressElement.remove();
  }
}

const STOP_THE_DOWNLOAD = "AMAZON_SUBTITLE_DOWNLOADER_STOP_THE_DOWNLOAD";
const TIMEOUT_ERROR = "AMAZON_SUBTITLE_DOWNLOADER_TIMEOUT_ERROR";
const DOWNLOADER_MENU = "subtitle-downloader-menu";

const DOWNLOADER_MENU_HTML = `
<ol>
<li class="header">Amazon subtitle downloader</li>
<li class="ep-title-in-filename">Add episode title to filename: <span></span></li>
<li class="incomplete">Scroll to the bottom to load more episodes</li>
</ol>
`;

const SCRIPT_CSS = `
#${DOWNLOADER_MENU} {
  position: absolute;
  display: none;
  width: 600px;
  top: 0;
  left: calc( 50% - 150px );
}
#${DOWNLOADER_MENU} ol {
  list-style: none;
  position: relative;
  width: 300px;
  background: #333;
  color: #fff;
  padding: 0;
  margin: 0;
  font-size: 12px;
  z-index: 99999998;
}
body:hover #${DOWNLOADER_MENU} { display: block; }
#${DOWNLOADER_MENU} li {
  padding: 10px;
  position: relative;
}
#${DOWNLOADER_MENU} li.header { font-weight: bold; }
#${DOWNLOADER_MENU} li:not(.header):hover { background: #666; }
#${DOWNLOADER_MENU} li:not(.header) {
  display: none;
  cursor: pointer;
}
#${DOWNLOADER_MENU}:hover li { display: block; }
#${DOWNLOADER_MENU} li > div {
  display: none;
  position: absolute;
  top: 0;
  left: 300px;
}
#${DOWNLOADER_MENU} li:hover > div { display: block; }

body:not(.asd-more-eps) #${DOWNLOADER_MENU} .incomplete { display: none; }

#${DOWNLOADER_MENU}:not(.series) .series{ display: none; }
#${DOWNLOADER_MENU}.series .not-series{ display: none; }
`;

const EXTENSIONS = {
  "TTMLv2": "ttml2",
  "DFXP": "dfxp"
}

let INFO_URL = null;
const INFO_CACHE = new Map();

let epTitleInFilename = localStorage.getItem("ASD_ep-title-in-filename") === "true";

const setEpTitleInFilename = () => {
  document.querySelector(`#${DOWNLOADER_MENU} .ep-title-in-filename > span`).innerHTML = (epTitleInFilename ? "on" : "off");
};

const toggleEpTitleInFilename = () => {
  epTitleInFilename = !epTitleInFilename;
  if(epTitleInFilename)
    localStorage.setItem("ASD_ep-title-in-filename", epTitleInFilename);
  else
    localStorage.removeItem("ASD_ep-title-in-filename");
  setEpTitleInFilename();
};

const showIncompleteWarning = () => {
  document.body.classList.add("asd-more-eps");
};
const hideIncompleteWarning = () => {
  try {
    document.body.classList.remove("asd-more-eps");
  }
  catch(ignore) {}
};
const scrollDown = () => {
  (
    document.querySelector('[data-testid="dp-episode-list-pagination-marker"]')
    || document.querySeledtor("#navFooter")
  ).scrollIntoView();
};

// XML to SRT
const parseTTMLLine = (line, parentStyle, styles) => {
  const topStyle = line.getAttribute("style") || parentStyle;
  let prefix = "";
  let suffix = "";
  let italic = line.getAttribute("tts:fontStyle") === "italic";
  let bold = line.getAttribute("tts:fontWeight") === "bold";
  let ruby = line.getAttribute("tts:ruby") === "text";
  if(topStyle !== null) {
    italic = italic || styles[topStyle][0];
    bold = bold || styles[topStyle][1];
    ruby = ruby || styles[topStyle][2];
  }

  if(italic) {
    prefix = "<i>";
    suffix = "</i>";
  }
  if(bold) {
    prefix += "<b>";
    suffix = "</b>" + suffix;
  }
  if(ruby) {
    prefix += "(";
    suffix = ")" + suffix;
  }

  let result = "";

  for(const node of line.childNodes) {
    if(node.nodeType === Node.ELEMENT_NODE) {
      const tagName = node.tagName.split(":").pop().toUpperCase();
      if(tagName === "BR") {
        result += "\n";
      }
      else if(tagName === "SPAN") {
        result += parseTTMLLine(node, topStyle, styles);
      }
      else {
        console.log("unknown node:", node);
        throw "unknown node";
      }
    }
    else if(node.nodeType === Node.TEXT_NODE) {
      result += prefix + node.textContent + suffix;
    }
  }

  return result;
};
const xmlToSrt = (xmlString, lang) => {
  try {
    let parser = new DOMParser();
    var xmlDoc = parser.parseFromString(xmlString, "text/xml");

    const styles = {};
    for(const style of xmlDoc.querySelectorAll("head styling style")) {
      const id = style.getAttribute("xml:id");
      if(id === null) throw "style ID not found";
      const italic = style.getAttribute("tts:fontStyle") === "italic";
      const bold = style.getAttribute("tts:fontWeight") === "bold";
      const ruby = style.getAttribute("tts:ruby") === "text";
      styles[id] = [italic, bold, ruby];
    }

    const regionsTop = {};
    for(const style of xmlDoc.querySelectorAll("head layout region")) {
      const id = style.getAttribute("xml:id");
      if(id === null) throw "style ID not found";
      const origin = style.getAttribute("tts:origin") || "0% 80%";
      const position = parseInt(origin.match(/\s(\d+)%/)[1]);
      regionsTop[id] = position < 50;
    }

    const topStyle = xmlDoc.querySelector("body").getAttribute("style");

    console.log(topStyle, styles, regionsTop);

    const lines = [];
    const textarea = document.createElement("textarea");

    let i = 0;
    for(const line of xmlDoc.querySelectorAll("body p")) {
      let parsedLine = parseTTMLLine(line, topStyle, styles);
      if(parsedLine != "") {
        if(lang.indexOf("ar") == 0)
          parsedLine = parsedLine.replace(/^(?!\u202B|\u200F)/gm, "\u202B");

        textarea.innerHTML = parsedLine;
        parsedLine = textarea.value;
        parsedLine = parsedLine.replace(/\n{2,}/g, "\n");

        const region = line.getAttribute("region");
        if(regionsTop[region] === true) {
          parsedLine = "{\\an8}" + parsedLine;
        }

        lines.push(++i);
        lines.push((line.getAttribute("begin") + " --> " + line.getAttribute("end")).replace(/\./g,","));
        lines.push(parsedLine);
        lines.push("");
      }
    }
    return lines.join("\n");
  }
  catch(e) {
    console.error(e);
    alert("Failed to parse XML subtitle file, see browser console for more details");
    return null;
  }
};

const sanitizeName = name => name.replace(/[:*?"<>|\\\/]+/g, "_").replace(/ /g, ".").replace(/\.{2,}/g, ".");

const asyncSleep = (seconds, value) => new Promise(resolve => {
  window.setTimeout(resolve, seconds * 1000, value);
});

const getName = (episodeId, addTitle, addSeriesName) => {
  let seasonNumber = 0;
  let digits = 2;
  let seriesName = "UNKNOWN";

  const info = INFO_CACHE.get(episodeId);
  const season = INFO_CACHE.get(info.show);
  if(typeof season !== "undefined") {
    seasonNumber = season.season;
    digits = season.digits;
    seriesName = season.title;
  }

  let title = (
    "S" + seasonNumber.toString().padStart(2, "0")
    + "E" + info.episode.toString().padStart(digits, "0")
  );

  if(addTitle)
    title += " " + info.title;

  if(addSeriesName)
    title = seriesName + " " + title;

  return title;
};

const createQueue = ids => {
  let archiveName = null;
  const names = new Set();
  const queue = new Map();
  for(const id of ids) {
    const info = JSON.parse(JSON.stringify(INFO_CACHE.get(id)));
    let name;
    if(info.type === "movie") {
      archiveName = sanitizeName(info.title + "." + info.year);
      name = archiveName;
    }
    else if(info.type === "episode") {
      name = sanitizeName(getName(id, epTitleInFilename, true));
      if(archiveName === null) {
        try {
          const series = INFO_CACHE.get(info.show);
          archiveName = sanitizeName(series.title + ".S" + series.season.toString().padStart(2, "0"));
        }
        catch(ignore) {}
      }
    }
    else
      continue;

    let subName = name;
    let i = 2;
    while(names.has(subName)) {
      sub_name = `${name}_${i}`;
      ++i;
    }
    names.add(subName);
    info.filename = subName;
    queue.set(id, info);
  }
  if(archiveName === null)
    archiveName = "subs";

  return [archiveName + ".zip", queue];
};

const getSubInfo = async envelope => {
  const response = await fetch(
    INFO_URL,
    {
      "credentials": "include",
      "method": "POST",
      "mode": "cors",
      "body": JSON.stringify({
        "globalParameters": {
          "deviceCapabilityFamily": "WebPlayer",
          "playbackEnvelope": envelope
        },
        "timedTextUrlsRequest": {
          "supportedTimedTextFormats": ["TTMLv2","DFXP"]
        }
      })
    }
  );
  const data = await response.json();
  if(data.globalError) {
    if(data.globalError.code && data.globalError.code === "PlaybackEnvelope.Expired")
      throw "authentication expired, refresh the page and try again";
    else
      throw data.globalError;
  }
  try {
    return data.timedTextUrls.result;
  }
  catch(error) {
    console.log(data);
    throw error;
  }
};

const download = async e => {
  const ids = e.target.getAttribute("data-id").split(";");
  if(ids.length === 1 && ids[0] === "")
    return;

  const [archiveName, queue] = createQueue(ids);
  const metadataProgress = new ProgressBar(queue.size);
  const subs = new Map();
  for(const [id, info] of queue) {
    const resultPromise = getSubInfo(info.envelope);
    let result;
    let error = null;
    try {
      // Promise.any isn't supported in all browsers, use Promise.race instead
      result = await Promise.race([resultPromise, metadataProgress.stop, asyncSleep(30, TIMEOUT_ERROR)]);
    }
    catch(e) {
      console.log(e);
      error = `error: ${e}`;
    }
    if(result === STOP_THE_DOWNLOAD)
      error = "stopped by user";
    else if(result === TIMEOUT_ERROR)
      error = "timeout error";
    if(error !== null) {
      alert(error);
      metadataProgress.destroy();
      return;
    }

    metadataProgress.increment();
    if(typeof result === "undefined")
      continue;

    for(const subtitle of [].concat(result.subtitleUrls || [], result.forcedNarrativeUrls || [])) {
      let lang = subtitle.languageCode;
      if(subtitle.subtype !== "Dialog")
        lang += `[${subtitle.subtype}]`;

      if(subtitle.type === "Subtitle") {}
      else if(subtitle.type === "Sdh")
        lang += "[cc]";
      else if(subtitle.type === "ForcedNarrative")
        lang += "-forced";
      else if(subtitle.type === "SubtitleMachineGenerated")
        lang += "[machine-generated]";
      else
        lang += `[${subtitle.type}]`;

      const name = info.filename + "." + lang;
      let subName = name;
      let i = 2;
      while(subs.has(subName)) {
        sub_name = `${name}_${i}`;
        ++i;
      }
      subs.set(
        subName,
        {
          "url": subtitle.url,
          "type": subtitle.format,
          "language": subtitle.languageCode
        }
      )
    }
  }
  metadataProgress.destroy();

  if(subs.size === 0) {
    alert("no subtitles found");
    return;
  }

  const _zip = new JSZip();
  const progress = new ProgressBar(subs.size);
  for(const [filename, details] of subs) {
    let extension = EXTENSIONS[details.type];
    if(typeof extension === "undefined") {
      const match = details.url.match(/\.([^\/]+)$/);
      if(match === null)
        extension = details.type.toLocaleLowerCase();
      else
        extension = match[1];
    }

    const subFilename = filename + "." + extension;
    const resultPromise = fetch(details.url, {"mode": "cors"});
    let result;
    let error = null;
    try {
      // Promise.any isn't supported in all browsers, use Promise.race instead
      result = await Promise.race([resultPromise, progress.stop, asyncSleep(30, TIMEOUT_ERROR)]);
    }
    catch(e) {
      error = `error: ${e}`;
    }
    if(result === STOP_THE_DOWNLOAD)
      error = STOP_THE_DOWNLOAD;
    else if(result === TIMEOUT_ERROR)
      error = "timeout error";
    if(error !== null) {
      if(error !== STOP_THE_DOWNLOAD)
        alert(error);
      break;
    }
    progress.increment();
    let data;
    if(extension === "ttml2") {
      data = await result.text();
      try {
        const srtFilename = filename + ".srt";
        const srtText = xmlToSrt(data, details.language);
        if(srtText !== null)
          _zip.file(srtFilename, srtText);
      }
      catch(ignore) {}
    }
    else
      data = await result.arrayBuffer();
    _zip.file(subFilename, data);
  }
  progress.destroy();

  const content = await _zip.generateAsync({type: "blob"});
  saveAs(content, archiveName);
};

const addDownloadButtons = parsedActions => {
  const menu = document.querySelector(`#${DOWNLOADER_MENU} > ol`);

  for(const [type, details] of parsedActions) {
    const li = document.createElement("li");
    let ids = null;
    if(type === "movie") {
      li.innerHTML = "Download subtitles for this movie";
      ids = details;
    }
    else if(type === "batch" && details.length > 0) {
      li.innerHTML = "Download subtitles for this batch <div><ol></ol></div>";
      ids = details.join(";");
      const ol = li.querySelector("ol");
      for(const episodeId of details) {
        const li = document.createElement("li");
        li.setAttribute("data-id", episodeId);
        li.innerHTML = getName(episodeId, true, false);
        ol.append(li);
      }
    }
    else
      continue;

    li.setAttribute("data-id", ids);
    li.addEventListener("click", download, true);
    menu.append(li);
  }
};

const parseActions = actions => {
  const parsed = [];
  const series = {};
  for(const [id, playback] of actions) {
    const info = INFO_CACHE.get(id);
    if(typeof info === "undefined")
      continue;
    if(info.type !== "movie" && info.type !== "episode")
      continue;
    if(typeof info.envelope !== "undefined")
      continue;

    try {
      let envelopeFound = false;
      for(const child of playback.main.children) {
        if(typeof child.playbackEnvelope !== "undefined") {
          info.envelope = child.playbackEnvelope;
          info.expiry = child.expiryTime;
          envelopeFound = true;
          break;
        }
      }
      if(!envelopeFound)
        continue;
    }
    catch(error) {
      continue;
    }

    if(info.type === "movie") {
      parsed.push(["movie", id])
    }
    else if(info.type === "episode") {
      let show = series[info.show];
      if(typeof show === "undefined") {
        series[info.show] = [];
        show = series[info.show];
      }
      show.push([id, info.episode]);
    }
  }

  for(const show of Object.values(series)) {
    show.sort((a, b) => a[1] - b[1]);
    const tmp = [];
    for(const [id, ep] of show) {
      tmp.push(id);
    }
    parsed.push(["batch", tmp]);
  }

  return parsed;
};

const parseDetails = (pageTitleId, state, id, details) => {
  if(typeof INFO_CACHE.get(id) !== "undefined")
    return;

  const info = {
    "title": details.title,
    "type": details.titleType
  };
  if(info.type === "movie") {
    info["year"] = details.releaseYear;
  }
  else if(info.type === "episode") {
    info["episode"] = details.episodeNumber;
    info["show"] = pageTitleId;
  }
  else if(info.type === "season") {
    info["season"] = details.seasonNumber;
    info["title"] = details.parentTitle;
    info["digits"] = 2;
    if(pageTitleId === id) {
      try {
        const epCount = state.episodeList.totalCardSize;
        info["digits"] = Math.max(Math.floor(Math.log10(epCount)), 1) + 1;
        if(epCount > state.episodeList.cardTitleIds.length)
          showIncompleteWarning();
      }
      catch(ignore) {}
    }
  }
  else {
    console.log(id, details);
    return;
  }

  INFO_CACHE.set(id, info);
};

const init = (url, fromFetch) => {
  let props = undefined;

  if(typeof fromFetch === "undefined") {
    if(INFO_URL !== null)
      return;

    INFO_URL = url;

    for(const templateElement of document.querySelectorAll('script[type="text/template"]')) {
      let data;
      try {
        data = JSON.parse(templateElement.innerHTML);
        props = data.props.body[0].props;
      }
      catch(ignore) {
        continue;
      }

      if(typeof props !== "undefined")
        break;
    }
  }
  else {
    props = fromFetch.page[0].assembly.body[0].props;
    INFO_CACHE.clear();
    hideIncompleteWarning();
    const menu = document.querySelector(`#${DOWNLOADER_MENU}`);
    if(menu !== null)
      menu.remove();
  }

  const pageTitleId = props.btf.state.pageTitleId;
  for(const [id, details] of Object.entries(props.btf.state.detail.detail)) {
    parseDetails(pageTitleId, props.btf.state, id, details);
  }

  const actions = [];
  for(const [id, action] of Object.entries(props.atf.state.action.atf)) {
    actions.push([id, action.playbackActions]);
  }
  for(const [id, action] of Object.entries(props.btf.state.action.btf)) {
    actions.push([id, action.playbackActions]);
  }
  const parsedActions = parseActions(actions);
  if(parsedActions.length === 0)
    return;

  if(document.querySelector(`#${DOWNLOADER_MENU}`) === null) {
    const menu = document.createElement("div");
    menu.id = DOWNLOADER_MENU;
    menu.innerHTML = DOWNLOADER_MENU_HTML;
    document.body.appendChild(menu);
    menu.querySelector(".ep-title-in-filename").addEventListener("click", toggleEpTitleInFilename);
    menu.querySelector(".incomplete").addEventListener("click", scrollDown);
    setEpTitleInFilename();
  }

  addDownloadButtons(parsedActions);
};

const parseEpisodes = data => {
  const pageTitleId = data.widgets.pageContext.pageTitleId;

  const actions = [];
  for(const episode of data.widgets.episodeList.episodes) {
    parseDetails(pageTitleId, {}, episode.titleID, episode.detail);
    actions.push([episode.titleID, episode.action.playbackActions]);
  }
  const parsedActions = parseActions(actions);
  addDownloadButtons(parsedActions);
};

const processMessage = e => {
  const {type, data} = e.detail;

  if(type === "url")
    init(data);
  else if(type === "episodes")
    parseEpisodes(data);
  else if(type === "page")
    init(null, data);
}

const injection = () => {
  // hijack functions
  ((open, realFetch) => {
    let urlGrabbed = false;

    XMLHttpRequest.prototype.open = function() {
      if(!urlGrabbed && arguments[1] && arguments[1].includes("/GetVodPlaybackResources?")) {
        window.dispatchEvent(new CustomEvent("amazon_sub_downloader_data", {detail: {type: "url", data: arguments[1]}}));
        urlGrabbed = true;
      }
      open.apply(this, arguments);
    };

    window.fetch = async (...args) => {
      const response = realFetch(...args);
      if(!urlGrabbed && args[0] && args[0].includes("/GetVodPlaybackResources?")) {
        window.dispatchEvent(new CustomEvent("amazon_sub_downloader_data", {detail: {type: "url", data: args[0]}}));
        urlGrabbed = true;
      }
      if(args[0] && args[0].includes("/getDetailWidgets?")) {
        const copied = (await response).clone();
        const data = await copied.json();
        window.dispatchEvent(new CustomEvent("amazon_sub_downloader_data", {detail: {type: "episodes", data: data}}));
      }
      else if(args[1] && args[1].headers && args[1].headers["x-requested-with"] === "WebSPA") {
        const copied = (await response).clone();
        const data = await copied.json();
        window.dispatchEvent(new CustomEvent("amazon_sub_downloader_data", {detail: {type: "page", data: data}}));
      }
      return response;
    };
  })(XMLHttpRequest.prototype.open, window.fetch);
}

window.addEventListener("amazon_sub_downloader_data", processMessage, false);

// inject script
const sc = document.createElement("script");
sc.innerHTML = "(" + injection.toString() + ")()";
document.head.appendChild(sc);
document.head.removeChild(sc);

// add CSS style
const s = document.createElement("style");
s.innerHTML = SCRIPT_CSS;
document.head.appendChild(s);

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址