您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
北教智云课堂功能增强(临时解决方案)
// ==UserScript== // @name 浙江大学智云课堂小助手 for 北教智云(临时) // @description 北教智云课堂功能增强(临时解决方案) // @namespace https://github.com/CoolSpring8/userscript // @supportURL https://github.com/CoolSpring8/userscript/issues // @version 0.5.9 // @author CoolSpring // @license MIT // @match *://interactivemeta.cmc.zju.edu.cn/* // @grant none // @require https://unpkg.com/[email protected]/worker.js // @run-at document-end // ==/UserScript== const M3U_EXTGRP_NAME = "ZJU-CMC" /* polyfill/shim begin */ // requestIdleCallback, for Safari if (!window.requestIdleCallback) { window.requestIdleCallback = function (callback) { return setTimeout(callback, 50) } } /* polyfill/shim end */ const querySelector = ( window.wrappedJSObject?.document || document ).querySelector.bind(document) const myWindow = window.wrappedJSObject || window class CmcHelper { constructor() { this.loaded = false this.features = [ { name: "重新加载播放器", className: "cmc-helper-reload-player", fn: this.reloadPlayer.bind(this), description: "播放卡住了点这个", }, { name: "获取当前视频地址", className: "cmc-helper-get-current-video-url", fn: this.getCurrentVideoURL.bind(this), description: "回放和直播中均可用", }, { name: "生成字幕", hidden: true, className: "cmc-helper-generate-srt", fn: this.generateSRT.bind(this), description: "可供本地播放器使用。不太靠谱的样子", }, { name: "导出语音识别内容", className: "cmc-helper-export-speech-text", fn: this.exportSpeechText.bind(this), description: "如题", }, { name: "打包下载PPT图片", className: "cmc-helper-download-ppt-images", fn: this.downloadPPTImages.bind(this), description: "如题", }, { name: "生成播放列表", disabled: true, hidden: true, className: "cmc-helper-generate-m3u", fn: this.generateM3U.bind(this), description: "可以在本地播放器中使用的m3u文件。也许期末很实用", }, ] const _init = () => { if (this.loaded) { return } const courseElem = querySelector(".container") const playerElem = querySelector(".container") if ( !this._isVueReady(courseElem) || !this._isVueReady(playerElem) || !("CmcPCPlayer" in myWindow || "CmcMobilePlayer" in myWindow) ) { requestIdleCallback(_init) return } this.courseVue = courseElem.__vue__ this.playerVue = playerElem.__vue__ if (!(this.playerVue.player && "setMask" in this.playerVue.player)) { requestIdleCallback(_init) return } const helperToolbar = document.createElement("div") this.features.forEach((feature) => helperToolbar.append(this._createButton(feature)) ) helperToolbar.style.display = "flex" helperToolbar.style.marginRight = "1.5px" const originalToolbar = querySelector(".help-wrapper") originalToolbar.prepend(helperToolbar) // setTimeout(this.removeMaskOnce, 500) // this.enablePPTEnhance() // this.enableSpeechEnhance() this.loaded = true console.log( `[CmcHelper] ${GM.info.script.name} v${GM.info.script.version} has been successfully loaded.` ) } requestIdleCallback(_init) } async downloadPPTImages() { const pptList = [...this.courseVue.PPTList] const dtf = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }) // eslint-disable-next-line no-undef const blob = await downloadZip( this._batchFetch( pptList.map((ppt) => ({ url: ppt.pptimgurl.replace(/^http:/, "https:"), info: { timeInVideo: ppt.created_sec }, })), (resp, url, info) => { const [filename_without_ext, ext] = this._splitFilenameFromURL(url) const p = dtf.formatToParts(Number(filename_without_ext)) return { input: resp, name: `${p[0].value}-${p[2].value}-${p[4].value}_${p[6].value}-${ p[8].value }-${p[10].value}__${this._secondsToDuration( info.timeInVideo )}.${ext}`, } } ) ).blob() const archiveFilename = `${document.title}.zip` this._saveBlobToFile(blob, archiveFilename) } enablePPTEnhance() { const _init = () => { this.pptVue = this.pptVue || querySelector(".ppt_container").__vue__ // feat: 允许PPT直接跳转到特定页码 const slider = document.createElement("input") slider.type = "range" slider.name = "ppt-index" slider.min = 1 slider.max = this.pptVue.PPTList.length slider.value = this.pptVue.currentPPTIdx + 1 // TODO:和实际的页码保持同步 slider.style.height = "16px" slider.style.margin = "-10px 0" slider.style.zIndex = 1 slider.addEventListener("input", (e) => { this.pptVue.currentPPTIdx = Number(e.currentTarget.value - 1) }) querySelector("#ppt").after(slider) // feat: 避免白色背景PPT切换页码时出现闪烁 querySelector("#ppt_canvas").getContext("2d").clearRect = () => {} // feat: 允许禁用PPT跟随 // TODO:现在官方提供了lockPPTFlag,研究此功能是否已可被替代 const t = document.createElement("div") t.className = "ppt-thumbtack" t.title = "不自动跳转到PPT最新一页" t.style.display = "inline" t.style.verticalAlign = "middle" t.style.cursor = "pointer" t.style.marginRight = "20px" t.style.color = "#fff" // icons from tabler-icons.io, licensed under MIT // https://github.com/tabler/tabler-icons/blob/master/LICENSE const iconPinned = `<svg xmlns="http://www.w3.org/2000/svg" id="ppt-pinned" class="icon icon-tabler icon-tabler-pinned" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" display="none"> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path d="M9 4v6l-2 4v2h10v-2l-2 -4v-6"></path> <line x1="12" y1="16" x2="12" y2="21"></line> <line x1="8" y1="4" x2="16" y2="4"></line> </svg>` const iconPinnedOff = `<svg xmlns="http://www.w3.org/2000/svg" id="ppt-pinned-off" class="icon icon-tabler icon-tabler-pinned-off" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <line x1="3" y1="3" x2="21" y2="21"></line> <path d="M15 4.5l-3.249 3.249m-2.57 1.433l-2.181 .818l-1.5 1.5l7 7l1.5 -1.5l.82 -2.186m1.43 -2.563l3.25 -3.251"></path> <line x1="9" y1="15" x2="4.5" y2="19.5"></line> <line x1="14.5" y1="4" x2="20" y2="9.5"></line> </svg>` t.insertAdjacentHTML("afterbegin", iconPinned) t.insertAdjacentHTML("afterbegin", iconPinnedOff) t.addEventListener("click", (e) => { const q = e.currentTarget.querySelector.bind(e.currentTarget) if (!this.pptPinned) { // 直播 this.__initCanvas = this.pptVue.initCanvas this.pptVue.initCanvas = (type) => { if (type !== "latest") { this.__initCanvas(type) } } // 回放 this.__computedPPTIndex = this.courseVue.computedPPTIndex this.courseVue.computedPPTIndex = () => {} this.pptPinned = true q("#ppt-pinned-off").setAttribute("display", "none") q("#ppt-pinned").removeAttribute("display") return } this.pptVue.initCanvas = this.__initCanvas this.courseVue.computedPPTIndex = this.__computedPPTIndex this.pptPinned = false q("#ppt-pinned").setAttribute("display", "none") q("#ppt-pinned-off").removeAttribute("display") }) querySelector(".ppt_page_btn").prepend(t) } // 因为每次大小窗口切换时部分页面元素都会被重新创建,所以需要再次修改 const observer = new MutationObserver((mutations) => { mutations .filter( (mutation) => mutation.type === "childList" && [...mutation.addedNodes].find( (node) => node.className === "ppt_container" ) !== undefined ) .forEach(_init) }) observer.observe(querySelector(".course-info__main"), { childList: true }) } enableSpeechEnhance() { const scopeId = this.courseVue.$options._scopeId const preventedTag = "data-cmchelper-prevented" const d = document.createElement("div") d.setAttribute(scopeId, "") // for style d.setAttribute(preventedTag, "false") d.className = "choose-item-info" const s = document.createElement("span") s.setAttribute(scopeId, "") s.innerText = "阻止滚动" s.innerHTML += " " // align with other switches const i = document.createElement("i") i.setAttribute(scopeId, "") i.className = "el-icon-check" i.style.display = "none" d.append(s, i) d.addEventListener("click", (e) => { const wrap = this.courseVue.$refs.spokenLanguageScrollbar.wrap const st = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop") if (e.currentTarget.getAttribute(preventedTag) === "false") { Object.defineProperty(wrap, "scrollTop", { get: function () { return st.get.apply(this, arguments) }, set: function () {}, configurable: true, }) e.currentTarget.setAttribute(preventedTag, "true") i.style.removeProperty("display") return } Object.defineProperty(wrap, "scrollTop", { get: function () { return st.get.apply(this, arguments) }, set: function () { st.set.apply(this, arguments) }, configurable: true, }) e.currentTarget.setAttribute("data-cmchelper-prevented", "false") i.style.display = "none" }) querySelector(".choose-item").prepend(d) } exportSpeechText() { window.open( URL.createObjectURL( new File( [ [...document.querySelectorAll(".trans-lan")] .map((e) => e.firstChild.textContent) .join("\n"), ], document.title, { type: "text/plain;charset=utf-8" } ) ), "_blank" ) } generateM3U() { const courseName = this.courseVue.courseName const teacherName = this.courseVue.teacherName // FIXME: a workaround for "Error: Permission denied to access object" in Firefox + Greasemonkey env const menuData = [...this.courseVue.menuData] const academicYear = JSON.parse(this.courseVue.liveInfo.information).kkxn const semester = JSON.parse(this.courseVue.liveInfo.information).kkxq const m3u = `#EXTM3U #PLAYLIST:${courseName} #EXTGRP:${M3U_EXTGRP_NAME} #EXTALB:${courseName} #EXTART:${teacherName} ${menuData .filter((menu) => "playback" in menu.content) .map( (menu) => `#EXTINF:${menu.duration},${menu.title}\n${menu.content.playback.url[0]}\n` ) .join("\n")}` this._saveTextToFile( m3u, `${courseName}-${teacherName}-${academicYear}${semester}.m3u` ) } generateSRT() { const url = this.playerVue.player.playervars.url const [filename_without_ext] = this._splitFilenameFromURL(url) // FIXME: a workaround for "Error: Permission denied to access object" in Firefox + Greasemonkey env const data = [...this.courseVue.videoTransContent] const subtitle = data .map( (item, index) => `${index} ${item.markTime},000 --> ${this._addTime( item.markTime, item.endPlayMs - item.playMs )},000 ${item.zhtext}` ) .join("\n\n") this._saveTextToFile(subtitle, `${filename_without_ext}.srt`) } getCurrentVideoURL() { if (this.playerVue.liveType === "live") { // may be changed to `multi` someday const sources = JSON.parse( cmcHelper.playerVue.liveUrl.replace("mutli-rate: ", "") ) prompt( "请复制到支持HLS的播放器(例如MPC-HC、PotPlayer、mpv)中使用", sources[0].url ) return } const url = querySelector("#cmc_player_video").src prompt("已选中,请自行复制到剪贴板", url) } reloadPlayer() { const time = this.playerVue.player.getPlayTime() this.playerVue.player.destroy() this.playerVue.initPlay() setTimeout(() => { this.playerVue.player.seekPlay(time) this.removeMaskOnce() }, 500) } removeMaskOnce() { // this.playerVue.player.setMask({}) // not working in Firefox try { querySelector(".expand-mask").remove() } catch (e) { console.error(`[CmcHelper] ${e}`) } } // there may be some better solutions _addTime(anchor, duration) { let hour = Number(anchor.slice(0, 2)) let minute = Number(anchor.slice(3, 5)) let second = Number(anchor.slice(6, 8)) second += duration if (second >= 60) { second -= 60 minute += 1 } if (minute >= 60) { minute -= 60 hour += 1 } this._twoDigitFormat = this._twoDigitFormat || new Intl.NumberFormat({ minimumIntegerDigits: 2 }) const f = this._twoDigitFormat return `${f.format(hour)}:${f.format(minute)}:${f.format(second)}` } _batchFetch(tasks, processFn) { return tasks.map(({ url, info }) => fetch(url) .then((resp) => processFn(resp, url, info)) .catch((e) => console.error(`[CmcHelper] ${e}`)) ) } _createButton({ name, disabled, hidden, className, fn, description }) { const button = document.createElement("button") button.innerText = name button.disabled = disabled button.title = disabled ? "由于智云课堂系统升级,该功能暂不可用" : description button.className = className button.style.margin = "1.5px" if (hidden) { button.style.display = "none" } button.addEventListener("click", (e) => { e.stopPropagation() fn() }) return button } _downloadSmallCrossOriginFile(url, filename) { fetch(url) .then((resp) => resp.blob()) .then((blob) => this._saveBlobToFile(blob, filename)) .catch((e) => alert(`[CmcHelper] 下载失败:${e}`)) } _isVueReady(elem) { return elem !== null && "__vue__" in elem } _saveBlobToFile(blob, filename) { const url = URL.createObjectURL(blob) this._triggerDownload(url, filename) URL.revokeObjectURL(url) } _saveTextToFile(text, filename) { const blob = new Blob([text]) this._saveBlobToFile(blob, filename) } _secondsToDuration(sec) { const hours = Math.floor(sec / 3600) const minutes = Math.floor((sec - hours * 3600) / 60) const seconds = sec - hours * 3600 - minutes * 60 return `${String(hours).padStart(2, "0")}-${String(minutes).padStart( 2, "0" )}-${String(seconds).padStart(2, "0")}` } _splitFilenameFromURL(url) { const filename = new URL(url).pathname.split("/").pop() const tmp = filename.split(".") const ext = tmp.pop() const filename_without_ext = tmp.join(".") return [filename_without_ext, ext] } _triggerDownload(url, filename) { const a = document.createElement("a") a.href = url a.download = filename a.click() } } const cmcHelper = new CmcHelper() // For debugging purposes myWindow.cmcHelper = cmcHelper