您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Allows you to download subtitles from Amazon Video
// ==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);