Greasy Fork is available in English.
在智云课堂页面添加批量下载视频的功能
// ==UserScript==
// @name 智云课堂批量下载
// @namespace http://tampermonkey.net/
// @version 1.1
// @description 在智云课堂页面添加批量下载视频的功能
// @author Cold_Ink
// @match https://classroom.zju.edu.cn/coursedetail*
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
console.log("智云课堂批量下载脚本已启动");
// 获取URL中的参数
function getQueryVariable(variable) {
const query = window.location.search.substring(1);
const vars = query.split("&");
for (let i = 0; i < vars.length; i++) {
const pair = vars[i].split("=");
if (pair[0] === variable) {
return decodeURIComponent(pair[1]);
}
if (decodeURIComponent(pair[0]) === variable) {
return decodeURIComponent(pair[1]);
}
}
return(false);
}
const course_id = getQueryVariable("course_id");
if (!course_id) {
console.log("course_id not found");
return;
}
console.log(`课程ID: ${course_id}`);
// CORS代理前缀(如果需要)
const corsProxy = ''; // 例如 'https://cors-anywhere.herokuapp.com/'
// 调用API获取课程目录
const apiUrl = `https://classroom.zju.edu.cn/courseapi/v2/course/catalogue?course_id=${course_id}`;
console.log(`API URL: ${apiUrl}`);
fetch(apiUrl, {
method: 'GET',
headers: {
"Content-Type": "application/json"
}
// 根据需要添加 credentials: 'include'
})
.then(response => {
console.log("API响应状态:", response.status);
return response.json();
})
.then(data => {
console.log("API响应数据:", data);
if (data.success && data.result && data.result.data) {
const items = data.result.data;
console.log(`获取到的课程目录项数量: ${items.length}`);
// 处理每个视频项
const videos = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
let title = item.title;
let videoUrl = null;
let available = true;
//if (item.pic) {
try {
const contentData = JSON.parse(item.content);
console.log(`解析第${i + 1}项的content字段成功`);
if (contentData.playback && contentData.playback.url) {
videoUrl = contentData.playback.url;
console.log(`第${i + 1}项视频URL: ${videoUrl}`);
} else if (contentData.url) { // 处理直接在"url"字段中的情况
videoUrl = contentData.url;
console.log(`第${i + 1}项视频URL: ${videoUrl}`);
} else {
available = false;
console.log(`第${i + 1}项没有可用的视频URL`);
}
} catch (e) {
console.error(`解析第${i + 1}项的content字段失败:`, e);
available = false;
}
//} else {
// available = false;
// console.log(`第${i + 1}项的pic字段为空,标记为暂无回放`);
//}
// 如果pic为空或videoUrl未获取到,则标记为暂无回放
if (!available || !videoUrl) {
title += "(暂无回放)";
}
videos.push({title: title, videoUrl: videoUrl, available: available, originalIndex: i});
}
console.log(`可下载的视频数量: ${videos.filter(v => v.available).length}`);
// 添加批量下载界面
addDownloadUI(videos);
} else {
console.log("从API获取数据失败,数据结构不符合预期");
}
})
.catch(error => {
console.log("Error fetching API:", error);
});
function addDownloadUI(videos) {
console.log("正在添加批量下载的用户界面");
// 创建容器
const container = document.createElement("div");
container.style.position = "fixed";
container.style.bottom = "10px";
container.style.right = "10px";
container.style.backgroundColor = "white";
container.style.padding = "15px";
container.style.border = "1px solid #ccc";
container.style.zIndex = 9999;
container.style.maxHeight = "80%";
container.style.overflowY = "auto";
container.style.fontSize = "14px";
container.style.lineHeight = "1.5";
container.style.boxShadow = "0 0 10px rgba(0,0,0,0.1)";
container.style.borderRadius = "5px";
container.style.width = "320px";
container.style.transition = "all 0.3s ease";
container.style.backgroundColor = "rgba(255, 255, 255, 0.95)";
container.style.display = "flex";
container.style.flexDirection = "column";
// 创建标题和最小化按钮
const header = document.createElement("div");
header.style.display = "flex";
header.style.justifyContent = "space-between";
header.style.alignItems = "center";
header.style.cursor = "default"; // 移除拖动功能
header.style.marginBottom = "10px";
const title = document.createElement("div");
title.style.fontWeight = "bold";
title.innerText = "批量下载视频";
header.appendChild(title);
const minimizeButton = document.createElement("button");
minimizeButton.innerText = "—";
minimizeButton.style.border = "none";
minimizeButton.style.background = "none";
minimizeButton.style.cursor = "pointer";
minimizeButton.style.fontSize = "16px";
minimizeButton.style.lineHeight = "1";
minimizeButton.style.padding = "0";
minimizeButton.style.marginLeft = "10px";
minimizeButton.title = "最小化";
minimizeButton.addEventListener("click", () => {
if (container.classList.contains("minimized")) {
// 恢复窗口
container.classList.remove("minimized");
// 显示所有相关元素
selectAllContainer.style.display = "flex";
downloadButton.style.display = "block";
overallProgressContainer.style.display = "block";
status.style.display = "block";
list.style.display = "block";
minimizeButton.innerText = "—";
minimizeButton.title = "最小化";
console.log("恢复下载界面");
} else {
// 最小化窗口
container.classList.add("minimized");
// 隐藏所有相关元素
selectAllContainer.style.display = "none";
downloadButton.style.display = "none";
overallProgressContainer.style.display = "none";
status.style.display = "none";
list.style.display = "none";
minimizeButton.innerText = "+";
minimizeButton.title = "恢复";
console.log("最小化下载界面");
}
});
header.appendChild(minimizeButton);
container.appendChild(header);
// 创建全选复选框容器
const selectAllContainer = document.createElement("div");
selectAllContainer.style.display = "flex";
selectAllContainer.style.alignItems = "center";
selectAllContainer.style.marginBottom = "10px";
const selectAllCheckbox = document.createElement("input");
selectAllCheckbox.type = "checkbox";
selectAllCheckbox.id = "selectAllCheckbox";
const selectAllLabel = document.createElement("label");
selectAllLabel.htmlFor = "selectAllCheckbox";
selectAllLabel.innerText = " 全选";
selectAllContainer.appendChild(selectAllCheckbox);
selectAllContainer.appendChild(selectAllLabel);
container.appendChild(selectAllContainer);
// 创建下载按钮
const downloadButton = document.createElement("button");
downloadButton.innerText = "下载选中视频";
downloadButton.style.display = "block";
downloadButton.style.marginTop = "10px";
downloadButton.style.width = "100%";
downloadButton.style.padding = "8px";
downloadButton.style.backgroundColor = "#4CAF50";
downloadButton.style.color = "white";
downloadButton.style.border = "none";
downloadButton.style.borderRadius = "3px";
downloadButton.style.cursor = "pointer";
downloadButton.style.fontSize = "14px";
downloadButton.addEventListener("mouseover", () => {
if (!downloadButton.disabled) {
downloadButton.style.backgroundColor = "#45a049";
}
});
downloadButton.addEventListener("mouseout", () => {
if (!downloadButton.disabled) {
downloadButton.style.backgroundColor = "#4CAF50";
}
});
container.appendChild(downloadButton);
// 创建状态显示区域
const status = document.createElement("div");
status.style.marginTop = "10px";
status.style.fontSize = "12px";
status.style.color = "#555";
container.appendChild(status);
// 创建整体进度条
const overallProgressContainer = document.createElement("div");
overallProgressContainer.style.width = "100%";
overallProgressContainer.style.backgroundColor = "#f3f3f3";
overallProgressContainer.style.borderRadius = "5px";
overallProgressContainer.style.marginTop = "10px";
overallProgressContainer.style.display = "none"; // 初始隐藏
container.appendChild(overallProgressContainer);
const overallProgressBar = document.createElement("div");
overallProgressBar.style.width = "0%";
overallProgressBar.style.height = "20px";
overallProgressBar.style.backgroundColor = "#4CAF50";
overallProgressBar.style.borderRadius = "5px";
overallProgressContainer.appendChild(overallProgressBar);
// 创建列表
const list = document.createElement("ul");
list.style.listStyle = "none";
list.style.padding = "0";
list.style.marginTop = "10px";
container.appendChild(list);
// 添加视频项
videos.forEach((video, index) => {
const listItem = document.createElement("li");
listItem.style.marginTop = "10px";
listItem.style.display = "block";
listItem.style.borderBottom = "1px solid #ddd";
listItem.style.paddingBottom = "10px";
const headerDiv = document.createElement("div");
headerDiv.style.display = "flex";
headerDiv.style.alignItems = "center";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = video.originalIndex; // 保存视频在原始数组中的索引
checkbox.className = "videoCheckbox";
checkbox.style.marginRight = "10px";
if (!video.available) {
checkbox.disabled = true;
}
const label = document.createElement("label");
label.style.flex = "1";
label.style.cursor = "pointer";
label.innerText = video.title;
headerDiv.appendChild(checkbox);
headerDiv.appendChild(label);
listItem.appendChild(headerDiv);
// 创建进度条
const progressContainer = document.createElement("div");
progressContainer.style.width = "100%";
progressContainer.style.backgroundColor = "#f3f3f3";
progressContainer.style.borderRadius = "5px";
progressContainer.style.marginTop = "5px";
progressContainer.style.display = "none"; // 初始隐藏
const progressBar = document.createElement("div");
progressBar.style.width = "0%";
progressBar.style.height = "10px";
progressBar.style.backgroundColor = "#4CAF50";
progressBar.style.borderRadius = "5px";
progressContainer.appendChild(progressBar);
// 创建速度和时间信息
const infoDiv = document.createElement("div");
infoDiv.style.marginTop = "5px";
infoDiv.style.fontSize = "12px";
infoDiv.style.color = "#555";
infoDiv.style.display = "none"; // 初始隐藏
infoDiv.innerText = "速度: 0 KB/s | 预计剩余时间: 0 s";
listItem.appendChild(progressContainer);
listItem.appendChild(infoDiv);
list.appendChild(listItem);
});
document.body.appendChild(container);
console.log("批量下载界面已添加到页面");
// 事件监听
selectAllCheckbox.addEventListener("change", function() {
const checkboxes = container.querySelectorAll(".videoCheckbox");
checkboxes.forEach(cb => {
if (!cb.disabled) {
cb.checked = this.checked;
}
});
console.log(`全选复选框状态改变为: ${this.checked}`);
});
downloadButton.addEventListener("click", function() {
console.log("下载按钮被点击");
status.innerText = "开始下载...";
const checkboxes = container.querySelectorAll(".videoCheckbox");
const selectedVideos = [];
checkboxes.forEach(cb => {
if (cb.checked) {
const videoIndex = parseInt(cb.value);
selectedVideos.push({ video: videos[videoIndex], index: videoIndex });
}
});
if (selectedVideos.length === 0) {
alert("请选择要下载的视频");
status.innerText = "";
console.log("未选择任何视频进行下载");
return;
}
console.log(`选中的视频数量: ${selectedVideos.length}`);
selectedVideos.forEach((videoObj, idx) => {
console.log(`准备下载 (${idx + 1}/${selectedVideos.length}): ${videoObj.video.title} - ${videoObj.video.videoUrl}`);
});
// 禁用下载按钮并更改文本
downloadButton.disabled = true;
downloadButton.innerText = "下载中...";
downloadButton.style.backgroundColor = "#888";
downloadButton.style.cursor = "not-allowed";
console.log("下载按钮已禁用,文本已更改为 '下载中...'");
// 显示整体进度条
overallProgressContainer.style.display = "block";
overallProgressBar.style.width = "0%";
console.log("显示整体进度条");
// 开始下载
let currentDownload = 0;
let completed = 0;
function downloadNext() {
if (currentDownload < selectedVideos.length) {
const videoObj = selectedVideos[currentDownload];
const video = videoObj.video;
const videoIndex = videoObj.index;
const listItem = list.children[videoIndex];
const progressContainer = listItem.querySelector("div:nth-child(2)");
const progressBar = progressContainer.querySelector("div");
const infoDiv = listItem.querySelector("div:nth-child(3)");
status.innerText = `正在下载 (${currentDownload + 1}/${selectedVideos.length}): ${video.title}`;
console.log(`开始下载 (${currentDownload + 1}/${selectedVideos.length}): ${video.title} - ${video.videoUrl}`);
progressContainer.style.display = "block";
infoDiv.style.display = "block";
// 创建 XHR 请求
const xhr = new XMLHttpRequest();
const downloadUrl = corsProxy + video.videoUrl;
console.log(`下载链接: ${downloadUrl}`);
xhr.open("GET", downloadUrl, true);
xhr.responseType = "blob";
let startTime = Date.now();
// 监听进度
xhr.onprogress = function(event) {
if (event.lengthComputable) {
const percentComplete = ((event.loaded / event.total) * 100).toFixed(2);
progressBar.style.width = percentComplete + "%";
// 计算下载速度和剩余时间
const currentTime = Date.now();
const elapsedTime = (currentTime - startTime) / 1000; // 秒
const bytesLoaded = event.loaded;
const speed = elapsedTime > 0 ? (bytesLoaded / elapsedTime / 1024).toFixed(2) : '0'; // KB/s
const remainingBytes = event.total - event.loaded;
const estimatedTime = speed > 0 ? (remainingBytes / 1024 / speed).toFixed(2) : '0';
infoDiv.innerText = `速度: ${speed} KB/s | 预计剩余时间: ${estimatedTime} s`;
//console.log(`下载进度 (${video.title}): ${percentComplete}% | 速度: ${speed} KB/s | 预计剩余时间: ${estimatedTime} s`);
}
};
// 监听完成
xhr.onload = function() {
if (xhr.status === 200 || xhr.status === 206) {
const blob = xhr.response;
const url = window.URL.createObjectURL(blob);
// 创建并点击隐藏的 <a> 标签
const a = document.createElement('a');
a.href = url;
a.download = sanitizeFilename(video.title) + ".mp4";
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 释放 Blob URL
window.URL.revokeObjectURL(url);
console.log(`下载已启动 (${video.title}): ${a.download}`);
} else {
console.error(`下载失败 (${video.title}): 状态码 ${xhr.status}`);
alert(`下载失败: ${video.title} (状态码 ${xhr.status})`);
}
completed++;
console.log(`完成下载: ${video.title} (${completed}/${selectedVideos.length})`);
// 更新整体进度条
const progress = ((completed / selectedVideos.length) * 100).toFixed(2);
overallProgressBar.style.width = progress + "%";
console.log(`整体进度: ${progress}%`);
currentDownload++;
// 触发下一个下载
setTimeout(downloadNext, 1000); // 1秒延迟
};
// 监听错误
xhr.onerror = function() {
console.error(`下载失败 (${video.title}): 网络错误`);
alert(`下载失败: ${video.title} (网络错误)`);
completed++;
console.log(`下载错误处理: ${video.title} (${completed}/${selectedVideos.length})`);
// 更新整体进度条
const progress = ((completed / selectedVideos.length) * 100).toFixed(2);
overallProgressBar.style.width = progress + "%";
console.log(`整体进度: ${progress}%`);
currentDownload++;
// 触发下一个下载
setTimeout(downloadNext, 1000); // 1秒延迟
};
console.log(`发送XHR请求 (${video.title})`);
xhr.send();
} else {
status.innerText = "所有下载已完成!请查看浏览器的下载管理器。";
console.log("所有下载已完成");
// 隐藏整体进度条
setTimeout(() => {
overallProgressContainer.style.display = "none";
console.log("隐藏整体进度条");
}, 5000);
// 恢复下载按钮
downloadButton.disabled = false;
downloadButton.innerText = "下载选中视频";
downloadButton.style.backgroundColor = "#4CAF50";
downloadButton.style.cursor = "pointer";
console.log("恢复下载按钮状态");
}
}
downloadNext();
});
}
/**
* 去除文件名中的非法字符
* @param {string} name - 原始文件名
* @returns {string} - 安全的文件名
*/
function sanitizeFilename(name) {
return name.replace(/[\/\\:*?"<>|]/g, '_');
}
})();