// ==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 = `
助学视频检测
当前 liveId:-
`; 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); })();