// ==UserScript==
// @name 青书学堂 写了一个无聊简单的小站点
// @namespace https://degree.qingshuxuetang.com/
// @version 1.5.0
// @description 可以进行检测学习状态判断:识别未学习/未达标/已达标,支持筛选、定位、手动播放、轻量探测,自动播,检测当前课程是否学习达标,自动检索未学习完成章节+答题+作业+测验+直播等可以进行反馈意见,后续进行更新,目前只能使用部分功能,反馈和可以做一些改动,或者推荐其它脚本给大家使用,均是免费的。小小停车站点 1077472126
// @author GPT-zxw
// @match https://degree.qingshuxuetang.com/*/Student/Course/CourseStudy*
// @grant GM_addStyle
// @grant GM_setClipboard
// @run-at document-idle
// @grant GM_getResourceText
// @grant GM_getValue
// @grant GM_info
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @run-at document-start
// @antifeature ads 助手解答
// @antifeature payment 付费答题
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const state = {
videos: [],
currentLiveId: '',
currentDetectedUrl: '',
autoDetecting: false,
autoDetectTimer: null,
videoUrlMap: new Map(),
panelCreated: false,
};
const CONFIG = {
initDelay: 1800,
detectDelay: 10000,
detectCheck1: 2000,
detectCheck2: 5000,
maxPreviewLen: 100,
};
function log(...args) {
console.log('[学习状态判断增强版]', ...args);
}
function safeText(el) {
return (el?.textContent || '').replace(/\s+/g, ' ').trim();
}
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function copyText(text) {
if (!text) return;
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text);
} else if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text);
}
}
function isMediaUrl(url) {
if (!url || typeof url !== 'string') return false;
const u = url.toLowerCase();
return (
u.includes('.m3u8') ||
u.includes('.mp4') ||
u.includes('.flv') ||
u.includes('playurl') ||
u.includes('video') ||
u.includes('resource') ||
u.includes('stream')
);
}
function normalizeUrl(url) {
try {
return decodeURIComponent(url);
} catch {
return url;
}
}
function parseStudyCount(text) {
if (!text) return 0;
const m = text.match(/已学习\s*(\d+)\s*次/);
return m ? Number(m[1]) : 0;
}
function parseChineseDurationToSeconds(text) {
if (!text) return 0;
let total = 0;
const h = text.match(/(\d+)\s*小时/);
const m = text.match(/(\d+)\s*分钟/);
const s = text.match(/(\d+)\s*秒/);
if (h) total += Number(h[1]) * 3600;
if (m) total += Number(m[1]) * 60;
if (s) total += Number(s[1]);
return total;
}
function parseClockToSeconds(str) {
if (!str) return 0;
const parts = str.split(':').map(Number).filter(n => !isNaN(n));
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
if (parts.length === 1) return parts[0];
return 0;
}
function formatSeconds(sec) {
sec = Math.max(0, Number(sec) || 0);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
return `${h}小时${m}分钟${s}秒`;
}
function getStatusText(v) {
if (v.unlearned) return '未学习';
if (v.reached) return '已达标';
if (v.needReview) return '未达标';
return '未知';
}
function getLiveIdFromNode(node) {
if (!node) return '';
const id = node.id || '';
let m = id.match(/live-(\d+)/i);
if (m) return m[1];
const href = node.getAttribute('href') || '';
m = href.match(/onLiveClick\(['"]?(\d+)['"]?\)/i);
if (m) return m[1];
const onclick = node.getAttribute('onclick') || '';
m = onclick.match(/onLiveClick\(['"]?(\d+)['"]?\)/i);
if (m) return m[1];
return '';
}
function findCourseTitleForNode(a) {
let box = a.closest('li');
let level = 0;
while (box && level < 8) {
const titleEl =
box.querySelector(':scope > a[data-toggle="open"] .title') ||
box.querySelector(':scope > a .title');
const txt = safeText(titleEl);
if (txt && !/^(\d+)(\s|$)/.test(txt)) return txt;
const prev = box.previousElementSibling;
if (prev) {
const prevTitle = prev.querySelector?.('.title');
const prevText = safeText(prevTitle || prev);
if (prevText && !/^(\d+)(\s|$)/.test(prevText)) return prevText;
}
box = box.parentElement?.closest('li');
level++;
}
const fallback = document.querySelector('a[data-toggle="open"] .title');
return safeText(fallback) || '未知课程';
}
function extractVideos() {
const nodes = Array.from(document.querySelectorAll('a[id^="live-"], a[href*="onLiveClick"], a[onclick*="onLiveClick"]'));
const map = new Map();
nodes.forEach((a, idx) => {
const liveId = getLiveIdFromNode(a);
if (!liveId || map.has(liveId)) return;
const titleText = safeText(a.querySelector('.title')) || safeText(a);
const studyText = safeText(a.querySelector('.mark')) || safeText(a.querySelector('.c_red')) || '';
const lessonNoMatch = titleText.match(/^(\d+)/);
const durationMatch = titleText.match(/时长\s*([\d:]+)/);
const studyCount = parseStudyCount(studyText);
const studiedSeconds = parseChineseDurationToSeconds(studyText);
const durationSeconds = parseClockToSeconds(durationMatch ? durationMatch[1] : '');
const reached = durationSeconds > 0 ? studiedSeconds >= durationSeconds : false;
const unlearned = !studyText || studyCount === 0 || studiedSeconds === 0;
const needReview = durationSeconds > 0 && studiedSeconds < durationSeconds;
const course = findCourseTitleForNode(a);
const urlInfo = state.videoUrlMap.get(String(liveId));
map.set(String(liveId), {
index: idx,
liveId: String(liveId),
id: a.id || `live-${liveId}`,
lessonNo: lessonNoMatch ? lessonNoMatch[1] : String(idx + 1),
duration: durationMatch ? durationMatch[1] : '',
durationSeconds,
titleText,
studyText,
studyCount,
studiedSeconds,
studied: !!studyText,
unlearned,
reached,
needReview,
course: course || '未知课程',
element: a,
detectedUrl: urlInfo?.url || '',
detectedSource: urlInfo?.source || '',
detectedTime: urlInfo?.time || '',
});
});
return Array.from(map.values()).sort((a, b) => {
if (a.course !== b.course) return a.course.localeCompare(b.course, 'zh');
return Number(a.lessonNo || 9999) - Number(b.lessonNo || 9999);
});
}
function setUrlForCurrent(url, source) {
if (!url) return;
url = normalizeUrl(url);
if (!isMediaUrl(url)) return;
state.currentDetectedUrl = url;
updateCurrentUrlBox(url);
if (!state.currentLiveId) return;
const old = state.videoUrlMap.get(state.currentLiveId);
if (!old || old.url !== url) {
const info = {
url,
source: source || 'unknown',
time: new Date().toLocaleString()
};
state.videoUrlMap.set(state.currentLiveId, info);
const item = state.videos.find(v => v.liveId === state.currentLiveId);
if (item) {
item.detectedUrl = info.url;
item.detectedSource = info.source;
item.detectedTime = info.time;
}
renderList();
log('捕获地址:', state.currentLiveId, source, url);
}
}
function capturePlayerUrl() {
const video = document.querySelector('video');
if (video) {
const urls = [
video.currentSrc,
video.src,
...Array.from(video.querySelectorAll('source')).map(s => s.src)
].filter(Boolean);
for (const url of urls) {
if (isMediaUrl(url)) {
setUrlForCurrent(url, 'video');
return;
}
}
}
const iframe = document.querySelector('iframe');
if (iframe?.src && isMediaUrl(iframe.src)) {
setUrlForCurrent(iframe.src, 'iframe');
}
}
function inspectTextForMedia(text, source) {
if (!text || typeof text !== 'string') return;
const urlRegex = /https?:\/\/[^"'\\\s<>]+/g;
const matches = text.match(urlRegex) || [];
for (const url of matches) {
if (isMediaUrl(url)) {
setUrlForCurrent(url, source);
return;
}
}
const fieldRegex = /"(playUrl|videoUrl|resourceUrl|fileUrl|url|hls|m3u8)"\s*:\s*"([^"]+)"/gi;
let m;
while ((m = fieldRegex.exec(text)) !== null) {
const maybeUrl = m[2].replace(/\\\//g, '/');
if (isMediaUrl(maybeUrl)) {
setUrlForCurrent(maybeUrl, `${source}:${m[1]}`);
return;
}
}
}
function hookFetch() {
if (window.__qst_fetch_hooked__) return;
window.__qst_fetch_hooked__ = true;
const rawFetch = window.fetch;
if (!rawFetch) return;
window.fetch = async function (...args) {
const reqUrl = typeof args[0] === 'string' ? args[0] : (args[0]?.url || '');
const res = await rawFetch.apply(this, args);
try {
if (isMediaUrl(reqUrl)) {
setUrlForCurrent(reqUrl, 'fetch-request');
}
const clone = res.clone();
const contentType = clone.headers.get('content-type') || '';
if (contentType.includes('json') || contentType.includes('text')) {
const text = await clone.text();
inspectTextForMedia(text, `fetch:${reqUrl}`);
}
} catch (e) {}
return res;
};
}
function hookXHR() {
if (window.__qst_xhr_hooked__) return;
window.__qst_xhr_hooked__ = true;
const rawOpen = XMLHttpRequest.prototype.open;
const rawSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__qst_url__ = url;
return rawOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (...args) {
this.addEventListener('load', function () {
try {
const url = this.__qst_url__ || '';
if (isMediaUrl(url)) {
setUrlForCurrent(url, 'xhr-request');
}
const type = this.getResponseHeader('content-type') || '';
if (type.includes('json') || type.includes('text')) {
inspectTextForMedia(this.responseText || '', `xhr:${url}`);
}
} catch (e) {}
});
return rawSend.apply(this, args);
};
}
function createPanel() {
if (state.panelCreated) return;
state.panelCreated = true;
const panel = document.createElement('div');
panel.id = 'qst-video-panel';
panel.innerHTML = `
`;
document.body.appendChild(panel);
document.getElementById('qst-refresh-btn')?.addEventListener('click', scanAndRender);
document.getElementById('qst-detect-all-btn')?.addEventListener('click', startAutoDetect);
document.getElementById('qst-export-btn')?.addEventListener('click', exportResults);
document.getElementById('qst-stop-detect-btn')?.addEventListener('click', stopAutoDetect);
document.getElementById('qst-copy-src-btn')?.addEventListener('click', copyCurrentUrl);
document.getElementById('qst-search-input')?.addEventListener('input', renderList);
document.getElementById('qst-only-unlearned')?.addEventListener('change', renderList);
document.getElementById('qst-only-not-reached')?.addEventListener('change', renderList);
document.getElementById('qst-only-with-url')?.addEventListener('change', renderList);
document.getElementById('qst-find-unlearned-btn')?.addEventListener('click', () => {
locateFirst(v => v.unlearned, '未找到未学习课程');
});
document.getElementById('qst-find-not-reached-btn')?.addEventListener('click', () => {
locateFirst(v => v.needReview, '未找到未达标课程');
});
document.getElementById('qst-toggle-btn')?.addEventListener('click', () => {
const body = panel.querySelector('.qst-body');
const hidden = body.style.display === 'none';
body.style.display = hidden ? 'block' : 'none';
document.getElementById('qst-toggle-btn').textContent = hidden ? '-' : '+';
});
}
function getFilteredVideos() {
const kw = (document.getElementById('qst-search-input')?.value || '').trim().toLowerCase();
const onlyUnlearned = document.getElementById('qst-only-unlearned')?.checked;
const onlyNotReached = document.getElementById('qst-only-not-reached')?.checked;
const onlyWithUrl = document.getElementById('qst-only-with-url')?.checked;
return state.videos.filter(v => {
if (onlyUnlearned && !v.unlearned) return false;
if (onlyNotReached && !v.needReview) return false;
if (onlyWithUrl && !v.detectedUrl) return false;
if (!kw) return true;
const text = [
v.course,
v.lessonNo,
v.duration,
v.titleText,
v.studyText,
v.liveId,
getStatusText(v)
].join(' ').toLowerCase();
return text.includes(kw);
});
}
function renderList() {
const listEl = document.getElementById('qst-video-list');
const statsEl = document.getElementById('qst-stats');
const currentLiveEl = document.getElementById('qst-current-liveid');
if (!listEl || !statsEl) return;
const filtered = getFilteredVideos();
const total = state.videos.length;
const studied = state.videos.filter(v => v.studied).length;
const withUrl = state.videos.filter(v => v.detectedUrl).length;
const unlearnedCount = state.videos.filter(v => v.unlearned).length;
const notReachedCount = state.videos.filter(v => v.needReview).length;
const reachedCount = state.videos.filter(v => v.reached).length;
statsEl.innerHTML = `
总视频:${total}
未学习:${unlearnedCount}
未达标:${notReachedCount}
已达标:${reachedCount}
已学习:${studied}
已抓地址:${withUrl}
显示:${filtered.length}
自动探测:${state.autoDetecting ? '进行中' : '关闭'}
`;
currentLiveEl.textContent = state.currentLiveId || '-';
if (!filtered.length) {
listEl.innerHTML = `没有匹配的视频
`;
return;
}
const groups = new Map();
filtered.forEach(v => {
const key = v.course || '未知课程';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(v);
});
let html = '';
for (const [course, items] of groups.entries()) {
html += `
${escapeHtml(course)} (${items.length})
`;
items.forEach(v => {
const preview = v.detectedUrl
? escapeHtml(v.detectedUrl.slice(0, CONFIG.maxPreviewLen) + (v.detectedUrl.length > CONFIG.maxPreviewLen ? '...' : ''))
: '未抓到地址';
const statusText = getStatusText(v);
const diffText = v.durationSeconds > 0
? `学习:${formatSeconds(v.studiedSeconds)} / 时长:${formatSeconds(v.durationSeconds)}`
: `学习:${formatSeconds(v.studiedSeconds)} / 时长:未知`;
html += `
第 ${escapeHtml(v.lessonNo)} 节
${escapeHtml(v.duration || '-')}
${escapeHtml(v.titleText || '-')}
liveId: ${escapeHtml(v.liveId)}
原始状态:${escapeHtml(v.studyText || '未学习')}
学习次数:${escapeHtml(String(v.studyCount || 0))}
判定状态:${escapeHtml(statusText)}
${escapeHtml(diffText)}
${preview}
定位
播放
探测
复制
`;
});
html += `
`;
}
listEl.innerHTML = html;
listEl.querySelectorAll('.qst-locate-btn').forEach(btn => {
btn.addEventListener('click', e => {
const liveId = e.currentTarget.getAttribute('data-live-id');
locateVideoById(liveId);
});
});
listEl.querySelectorAll('.qst-play-btn').forEach(btn => {
btn.addEventListener('click', e => {
const liveId = e.currentTarget.getAttribute('data-live-id');
playVideoById(liveId, false);
});
});
listEl.querySelectorAll('.qst-detect-btn').forEach(btn => {
btn.addEventListener('click', e => {
const liveId = e.currentTarget.getAttribute('data-live-id');
playVideoById(liveId, true);
});
});
listEl.querySelectorAll('.qst-copy-btn').forEach(btn => {
btn.addEventListener('click', e => {
const liveId = e.currentTarget.getAttribute('data-live-id');
const item = state.videos.find(v => v.liveId === liveId);
if (!item?.detectedUrl) return alert('该视频还没有地址');
copyText(item.detectedUrl);
alert('已复制');
});
});
}
function updateCurrentUrlBox(url) {
const el = document.getElementById('qst-video-src');
if (el) el.value = url || '';
}
function locateVideoById(liveId) {
const item = state.videos.find(v => v.liveId === String(liveId));
if (!item?.element) {
alert('未找到视频节点');
return;
}
try {
item.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
item.element.style.outline = '2px solid #1976d2';
item.element.style.borderRadius = '6px';
setTimeout(() => {
item.element.style.outline = '';
item.element.style.borderRadius = '';
}, 2500);
} catch (e) {}
}
function locateFirst(predicate, emptyMsg) {
const target = state.videos.find(predicate);
if (!target) {
alert(emptyMsg || '未找到目标课程');
return;
}
locateVideoById(target.liveId);
}
function playVideoById(liveId, detectAfterPlay) {
const item = state.videos.find(v => v.liveId === String(liveId));
if (!item?.element) {
alert('未找到视频节点');
return;
}
state.currentLiveId = String(liveId);
state.currentDetectedUrl = '';
updateCurrentUrlBox('');
renderList();
try {
if (window.CoursewareNodesManager && typeof window.CoursewareNodesManager.onLiveClick === 'function') {
window.CoursewareNodesManager.onLiveClick(String(liveId));
} else {
item.element.click();
}
} catch (e) {
try { item.element.click(); } catch {}
}
try {
item.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch (e) {}
if (detectAfterPlay) {
setTimeout(capturePlayerUrl, CONFIG.detectCheck1);
setTimeout(capturePlayerUrl, CONFIG.detectCheck2);
}
}
function startAutoDetect() {
if (state.autoDetecting) return;
const pending = state.videos.filter(v => !v.detectedUrl);
if (!pending.length) {
alert('没有需要探测的视频');
return;
}
state.autoDetecting = true;
renderList();
const runNext = () => {
if (!state.autoDetecting) return;
const next = state.videos.find(v => !v.detectedUrl);
if (!next) {
stopAutoDetect();
alert('探测完成');
return;
}
playVideoById(next.liveId, true);
state.autoDetectTimer = setTimeout(() => {
capturePlayerUrl();
runNext();
}, CONFIG.detectDelay);
};
runNext();
}
function stopAutoDetect() {
state.autoDetecting = false;
if (state.autoDetectTimer) {
clearTimeout(state.autoDetectTimer);
state.autoDetectTimer = null;
}
renderList();
}
function exportResults() {
const data = state.videos.map(v => ({
course: v.course,
lessonNo: v.lessonNo,
titleText: v.titleText,
duration: v.duration,
durationSeconds: v.durationSeconds,
liveId: v.liveId,
studied: v.studied,
studyText: v.studyText,
studyCount: v.studyCount,
studiedSeconds: v.studiedSeconds,
unlearned: v.unlearned,
needReview: v.needReview,
reached: v.reached,
statusText: getStatusText(v),
detectedUrl: v.detectedUrl,
detectedSource: v.detectedSource,
detectedTime: v.detectedTime
}));
copyText(JSON.stringify(data, null, 2));
alert('反馈意见:1077472126');
}
function copyCurrentUrl() {
if (!state.currentDetectedUrl) {
alert('当前没有地址');
return;
}
copyText(state.currentDetectedUrl);
alert('已复制当前地址');
}
function scanAndRender() {
state.videos = extractVideos();
renderList();
log('扫描到视频数量:', state.videos.length);
log('未学习数量:', state.videos.filter(v => v.unlearned).length);
log('未达标数量:', state.videos.filter(v => v.needReview).length);
}
function initStyles() {
GM_addStyle(`
#qst-video-panel {
position: fixed;
top: 60px;
right: 16px;
width: 460px;
max-height: 85vh;
z-index: 999999;
background: #fff;
border: 1px solid #dbe3ef;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.16);
overflow: hidden;
font-size: 14px;
color: #1f2937;
}
#qst-video-panel * {
box-sizing: border-box;
font-family: Arial, "Microsoft YaHei", sans-serif;
}
#qst-video-panel .qst-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: linear-gradient(135deg, #1976d2, #42a5f5);
color: #fff;
font-weight: 700;
}
#qst-video-panel button {
border: none;
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
background: #f3f4f6;
margin-left: 6px;
}
#qst-video-panel .qst-body {
padding: 12px;
background: #f8fafc;
overflow: auto;
max-height: calc(85vh - 48px);
}
#qst-video-panel .qst-toolbar input {
width: 100%;
padding: 9px 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
}
#qst-video-panel .qst-toolbar-row {
margin-top: 8px;
display: flex;
gap: 12px;
flex-wrap: wrap;
font-size: 13px;
}
#qst-video-panel .qst-toolbar-btns button {
margin-left: 0;
}
#qst-video-panel .qst-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0;
}
#qst-video-panel .qst-stats span {
font-size: 12px;
background: #e8f1ff;
color: #1e40af;
padding: 4px 8px;
border-radius: 999px;
}
#qst-video-panel .qst-current-box {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px;
margin-bottom: 12px;
}
#qst-video-panel textarea {
width: 100%;
min-height: 64px;
margin-top: 8px;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px;
resize: vertical;
}
#qst-video-panel .qst-url-btns {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
#qst-video-panel .qst-group {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
}
#qst-video-panel .qst-group-title {
background: #f1f5f9;
padding: 10px;
font-weight: 700;
}
#qst-video-panel .qst-group-list {
padding: 8px;
}
#qst-video-panel .qst-item {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px;
margin-bottom: 8px;
background: #fff;
}
#qst-video-panel .qst-item.unlearned { border-left: 4px solid #f59e0b; }
#qst-video-panel .qst-item.not-reached { border-left: 4px solid #ef4444; }
#qst-video-panel .qst-item.reached { border-left: 4px solid #22c55e; }
#qst-video-panel .qst-item.has-url { background: #f0fdf4; }
#qst-video-panel .qst-item-main {
display: flex;
justify-content: space-between;
font-weight: 700;
margin-bottom: 4px;
}
#qst-video-panel .qst-item-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 4px;
color: #111827;
}
#qst-video-panel .qst-item-meta,
#qst-video-panel .qst-item-sub,
#qst-video-panel .qst-item-url {
font-size: 12px;
color: #4b5563;
margin-bottom: 4px;
word-break: break-all;
}
#qst-video-panel .qst-item-btns {
display: flex;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
#qst-video-panel .qst-empty {
text-align: center;
color: #94a3b8;
padding: 20px 0;
}
#qst-video-panel .qst-status {
font-weight: 700;
}
#qst-video-panel .s-unlearned { color: #d97706; }
#qst-video-panel .s-not-reached { color: #dc2626; }
#qst-video-panel .s-reached { color: #16a34a; }
`);
}
function init() {
initStyles();
hookFetch();
hookXHR();
createPanel();
setTimeout(() => {
scanAndRender();
capturePlayerUrl();
}, CONFIG.initDelay);
log('学习状态判断增强版初始化完成');
}
setTimeout(init, 1000);
})();