// ==UserScript== // @name 学习通课件解析助手 // @namespace https://github.com/local/chaoxing-courseware-helper // @version 0.2.0 // @description 在已登录的学习通章节页解析课件,并下载自己有权访问的资源。 // @license MIT // @match *://*.chaoxing.com/* // @match *://*.fanya.chaoxing.com/* // @noframes // @run-at document-idle // @grant GM_xmlhttpRequest // @grant GM_download // @grant GM_openInTab // @grant GM_setClipboard // @grant unsafeWindow // @connect * // ==/UserScript== /* * 仅使用浏览器当前已登录帐号可访问的接口;不保存帐号、密码或 Cookie。 * 学习通页面与接口可能调整,主要维护点是 getAttachments() 与 resourceFromStatus()。 */ (() => { "use strict"; // 学习通的课件阅读器会嵌套多个 iframe。用户脚本默认会在每一层都运行, // 因而会产生多个悬浮按钮;只允许顶层页面创建唯一的解析面板。 if (window.top !== window.self) return; const STATUS_API = "https://mooc1.chaoxing.com/ananas/status/"; const CARDS_API = "https://mooc1.chaoxing.com/knowledge/cards"; const FILE_EXTENSIONS = { pdf: "pdf", ppt: "ppt", pptx: "ppt", pps: "ppt", ppsx: "ppt", doc: "word", docx: "word", wps: "word", xls: "sheet", xlsx: "sheet", csv: "sheet", mp4: "video", m3u8: "video", flv: "video", avi: "video", mov: "video", mkv: "video", zip: "archive", rar: "archive", "7z": "archive", }; const TYPE_LABELS = { pdf: "PDF", ppt: "PPT", word: "WORD", sheet: "表格", video: "视频", archive: "压缩包", file: "文件", }; const state = { open: false, parsing: false, downloading: false, resources: [], drag: null, suppressTabClick: false, }; const TAB_POSITION_KEY = "cxh-tab-position-v1"; const TAB_MARGIN = 10; const TAB_SNAP_STEP = 16; function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function requestJson(url, referer = location.href) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, responseType: "json", timeout: 30_000, headers: { Referer: referer }, onload(response) { if (response.status >= 200 && response.status < 300) { resolve(response.response); } else { reject(new Error(`HTTP ${response.status}`)); } }, onerror: () => reject(new Error("网络请求失败")), ontimeout: () => reject(new Error("请求超时")), }); }); } function parseMArgText(text) { const match = text.match(/mArg\s*=\s*(\{[\s\S]*?\});\s*(?:\n|<\/script>)/); if (!match) return null; try { return JSON.parse(match[1]); } catch { return null; } } function pageMArg() { try { if (typeof unsafeWindow !== "undefined" && unsafeWindow.mArg) return unsafeWindow.mArg; } catch { // 某些脚本管理器隔离页面变量,继续从 HTML 中读取。 } for (const script of document.scripts) { const value = parseMArgText(script.textContent || ""); if (value) return value; } return null; } function valueFromPage(names) { const search = new URLSearchParams(location.search); for (const name of names) { const queryValue = search.get(name); if (queryValue) return queryValue; const element = document.querySelector(`[name="${name}"], #${name}`); if (element && "value" in element && element.value) return element.value; } return ""; } function chapterContext() { return { courseid: valueFromPage(["courseid", "courseId"]), clazzid: valueFromPage(["clazzid", "clazzId"]), knowledgeid: valueFromPage(["knowledgeid", "knowledgeId", "chapterId"]), cpi: valueFromPage(["cpi"]), }; } async function getAttachments() { const mArg = pageMArg(); if (Array.isArray(mArg?.attachments) && mArg.attachments.length) return mArg.attachments; // 有些章节内容在 iframe 或异步加载,顶层页面没有 mArg;此时复用原项目的 cards 回退接口。 const context = chapterContext(); if (!context.courseid || !context.clazzid || !context.knowledgeid || !context.cpi) { throw new Error("未读到本章课程参数。请进入具体章节的课件页面后再解析。"); } const query = new URLSearchParams({ clazzid: context.clazzid, courseid: context.courseid, knowledgeid: context.knowledgeid, num: "0", ut: "s", cpi: context.cpi, v: "2025-0424-1038-4", mooc2: "1", isMicroCourse: "false", editorPreview: "0", }); const html = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: `${CARDS_API}?${query}`, timeout: 30_000, headers: { Referer: location.href }, onload(response) { if (response.status >= 200 && response.status < 300) resolve(response.responseText); else reject(new Error(`章节信息请求失败:HTTP ${response.status}`)); }, onerror: () => reject(new Error("章节信息请求失败")), }); }); const cardsMArg = parseMArgText(html); if (!Array.isArray(cardsMArg?.attachments)) throw new Error("该章节未返回可解析的附件信息。"); return cardsMArg.attachments; } function extensionOf(value) { const path = String(value).split("?")[0].split("#")[0]; return (path.match(/\.([a-z0-9]{1,8})$/i)?.[1] || "").toLowerCase(); } function inferKind(name, url, attachmentType) { const extension = extensionOf(name) || extensionOf(url); if (FILE_EXTENSIONS[extension]) return FILE_EXTENSIONS[extension]; const type = String(attachmentType || "").toLowerCase(); if (type.includes("video")) return "video"; if (type.includes("pdf")) return "pdf"; if (type.includes("ppt")) return "ppt"; if (type.includes("word") || type.includes("doc")) return "word"; return "file"; } function safeFileName(name, url, kind) { let result = String(name || "课件").replace(/[<>:"/\\|?*\x00-\x1F]/g, "_").trim(); if (!extensionOf(result)) { const extension = extensionOf(url) || ({ pdf: "pdf", ppt: "ppt", word: "docx", video: "mp4" }[kind] || ""); if (extension) result += `.${extension}`; } return result || "课件"; } function resourceFromStatus(attachment, status) { const property = attachment.property || {}; const objectId = property.objectid || property.objectId || attachment.objectid || attachment.objectId; const name = property.name || attachment.name || objectId || "未命名课件"; const attachmentType = attachment.type || property.type || ""; const candidates = [status?.pdf, status?.download, status?.http]; const url = candidates.find((value) => typeof value === "string" && /^https?:\/\//i.test(value)); if (!url) return null; const kind = inferKind(name, url, attachmentType); return { id: `${objectId || name}:${url}`, objectId, name: safeFileName(name, url, kind), url, kind, selected: true, }; } function selectedResources() { return state.resources.filter((resource) => resource.selected); } function setStatus(message, tone = "") { const status = document.querySelector("#cxh-status"); if (!status) return; status.className = `cxh-status ${tone}`; status.textContent = message; } function renderResults() { const list = document.querySelector("#cxh-results"); const count = document.querySelector("#cxh-count"); const download = document.querySelector("#cxh-download"); if (!list || !count || !download) return; count.textContent = `${state.resources.length} 个文件`; download.disabled = state.downloading || selectedResources().length === 0; download.textContent = state.downloading ? "下载中…" : `下载选中(${selectedResources().length})`; if (!state.resources.length) { list.innerHTML = '
解析后将在这里列出文件
'; return; } list.innerHTML = state.resources.map((resource, index) => ` `).join(""); list.querySelectorAll(".cxh-check").forEach((checkbox) => { checkbox.addEventListener("change", (event) => { const index = Number(event.currentTarget.dataset.index); state.resources[index].selected = event.currentTarget.checked; renderResults(); }); }); } function setOpen(open) { state.open = open; const root = document.querySelector("#cxh-root"); const tab = document.querySelector("#cxh-tab"); if (!root || !tab) return; root.classList.toggle("cxh-open", open); tab.setAttribute("aria-expanded", String(open)); } function clamp(value, minimum, maximum) { return Math.min(Math.max(value, minimum), maximum); } function tabTopRange(root) { const max = Math.max(TAB_MARGIN, window.innerHeight - root.offsetHeight - TAB_MARGIN); return { min: TAB_MARGIN, max }; } function savedTabRatio() { try { const value = Number.parseFloat(localStorage.getItem(TAB_POSITION_KEY) || ""); return Number.isFinite(value) ? clamp(value, 0, 1) : 0.5; } catch { return 0.5; } } function setTabTop(root, top, persist = false) { const range = tabTopRange(root); const safeTop = clamp(top, range.min, range.max); root.style.top = `${safeTop}px`; root.style.transform = "none"; if (!persist) return; const ratio = range.max === range.min ? 0 : (safeTop - range.min) / (range.max - range.min); try { localStorage.setItem(TAB_POSITION_KEY, String(ratio)); } catch { // 隐私模式或站点策略禁用 localStorage 时,仍可在当前页面拖动。 } } function restoreTabPosition(root) { const range = tabTopRange(root); setTabTop(root, range.min + (range.max - range.min) * savedTabRatio()); } function snapTabTop(root, top) { const range = tabTopRange(root); const snapped = range.min + Math.round((top - range.min) / TAB_SNAP_STEP) * TAB_SNAP_STEP; return clamp(snapped, range.min, range.max); } function bindTabDrag(root) { const tab = root.querySelector("#cxh-tab"); tab.addEventListener("pointerdown", (event) => { if (event.button !== 0 && event.pointerType !== "touch") return; const rect = root.getBoundingClientRect(); // 将初始的 50% + translate 位置转换为像素位置,拖动时不会跳动。 setTabTop(root, rect.top); state.drag = { pointerId: event.pointerId, startY: event.clientY, startTop: rect.top, moved: false, }; tab.setPointerCapture?.(event.pointerId); }); tab.addEventListener("pointermove", (event) => { const drag = state.drag; if (!drag || drag.pointerId !== event.pointerId) return; const offset = event.clientY - drag.startY; if (Math.abs(offset) > 4) drag.moved = true; if (!drag.moved) return; setTabTop(root, drag.startTop + offset); event.preventDefault(); }); const finishDrag = (event) => { const drag = state.drag; if (!drag || drag.pointerId !== event.pointerId) return; if (drag.moved) { const currentTop = root.getBoundingClientRect().top; setTabTop(root, snapTabTop(root, currentTop), true); // pointerup 后仍可能触发 click;阻止这次 click 以保持原有展开状态。 state.suppressTabClick = true; } state.drag = null; if (tab.hasPointerCapture?.(event.pointerId)) tab.releasePointerCapture(event.pointerId); }; tab.addEventListener("pointerup", finishDrag); tab.addEventListener("pointercancel", finishDrag); window.addEventListener("resize", () => restoreTabPosition(root)); } function bindOutsideDismiss(root) { // 顶层页面自身的内容区域:点击悬浮书签和下载框之外即收起。 document.addEventListener("pointerdown", (event) => { if (state.open && !root.contains(event.target)) setOpen(false); }, true); // 学习通的课件正文通常在 iframe 中,iframe 内部的点击不会冒泡到顶层文档; // 点击后顶层窗口会失焦,因此在焦点转入 iframe 时同样收起面板。 window.addEventListener("blur", () => { window.setTimeout(() => { if (state.open && document.activeElement?.tagName === "IFRAME") setOpen(false); }, 0); }); } async function parseCourseware() { if (state.parsing) return; state.parsing = true; const button = document.querySelector("#cxh-parse"); button.disabled = true; button.textContent = "解析中…"; state.resources = []; renderResults(); setStatus("正在读取本章附件…"); try { const attachments = await getAttachments(); setStatus(`正在解析 ${attachments.length} 个附件…`); const parsed = await Promise.all(attachments.map(async (attachment) => { const property = attachment.property || {}; const objectId = property.objectid || property.objectId || attachment.objectid || attachment.objectId; if (!objectId) return null; try { const status = await requestJson(`${STATUS_API}${encodeURIComponent(objectId)}?flag=normal`); return resourceFromStatus(attachment, status); } catch (error) { console.warn("[学习通课件解析助手] 附件解析失败", objectId, error); return null; } })); const seen = new Set(); state.resources = parsed.filter((resource) => resource && !seen.has(resource.url) && seen.add(resource.url)); renderResults(); setStatus(state.resources.length ? `已找到 ${state.resources.length} 个可下载文件。` : "未找到可下载文件。", state.resources.length ? "success" : "warning"); } catch (error) { setStatus(error.message || "解析失败,请刷新章节页面后重试。", "error"); } finally { state.parsing = false; button.disabled = false; button.textContent = "一键解析本章课件"; } } function downloadOne(resource) { return new Promise((resolve, reject) => { GM_download({ url: resource.url, name: resource.name, saveAs: false, onload: resolve, onerror: reject, ontimeout: reject, }); }); } const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); async function downloadSelected() { if (state.downloading) return; const resources = selectedResources(); if (!resources.length) return; state.downloading = true; renderResults(); let failed = 0; for (let index = 0; index < resources.length; index += 1) { setStatus(`正在下载 ${index + 1}/${resources.length}:${resources[index].name}`); try { await downloadOne(resources[index]); } catch (error) { failed += 1; console.warn("[学习通课件解析助手] 下载失败", resources[index], error); } // 避免浏览器把连续下载判定为异常弹窗。 await delay(180); } state.downloading = false; renderResults(); setStatus(failed ? `下载完成,${failed} 个文件失败。` : `已提交 ${resources.length} 个文件到浏览器下载。`, failed ? "warning" : "success"); } function createUi() { if (document.querySelector("#cxh-root")) return; const root = document.createElement("aside"); root.id = "cxh-root"; root.innerHTML = `解析后将在这里列出文件
仅解析当前帐号可访问的资源