// ==UserScript==
// @name 智慧教材极速下载
// @namespace https://scriptcat.org/zh-CN/users/211428
// @version 1.0.0
// @description 一键下载国家中小学智慧教育平台教材
// @author ganjueqi
// @match https://basic.smartedu.cn/*?*contentId=*
// @match https://www.smartedu.cn/*?*contentId=*
// @match https://teacher.vocational.smartedu.cn/*?*contentId=*
// @match https://core.teacher.vocational.smartedu.cn/*?*contentId=*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_openInTab
// @connect s-file-2.ykt.cbern.com.cn
// @run-at document-end
// @tag 教材下载 下载
// @icon data:image/svg+xml,
// @license GPL-3.0
// ==/UserScript==
(function () {
'use strict';
// ---------- 样式(AI 风格,增加 cursor: grab) ----------
const style = document.createElement('style');
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes fadeSlideIn {
0% { opacity: 0; transform: translateY(20px) scale(0.9); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes fadeSlideOut {
0% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(10px) scale(0.9); }
}
@keyframes pulseGlow {
0%, 100% {
box-shadow: 0 0 20px rgba(71, 118, 230, 0.5), 0 0 60px rgba(142, 84, 233, 0.3);
}
50% {
box-shadow: 0 0 40px rgba(71, 118, 230, 0.8), 0 0 80px rgba(142, 84, 233, 0.5);
}
}
.edu-download-btn {
position: fixed;
bottom: 30px;
left: 30px;
z-index: 9999;
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #4776E6, #8E54E9);
animation: pulseGlow 3s ease-in-out infinite;
cursor: grab;
touch-action: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease;
user-select: none;
border: none;
outline: none;
color: #fff;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.edu-download-btn::before {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.edu-download-btn:hover {
transform: scale(1.10);
animation: none;
box-shadow: 0 0 40px rgba(71, 118, 230, 0.9), 0 0 80px rgba(142, 84, 233, 0.6);
}
.edu-download-btn:active {
transform: scale(0.92);
cursor: grabbing;
}
.edu-download-btn .icon {
font-size: 24px;
line-height: 1;
transition: transform 0.3s;
position: relative;
z-index: 1;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
}
.edu-download-btn .label {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.8px;
margin-top: 2px;
position: relative;
z-index: 1;
text-shadow: 0 2px 8px rgba(0,0,0,0.25);
}
.edu-download-btn.loading .icon {
animation: spin 1s linear infinite;
}
.edu-download-btn.loading {
animation: none;
}
.edu-toast {
position: fixed;
bottom: 130px;
left: 30px;
background: rgba(20, 22, 36, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #fff;
padding: 12px 24px;
border-radius: 16px;
font-size: 14px;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
z-index: 10000;
animation: fadeSlideIn 0.3s ease;
max-width: 320px;
text-align: center;
pointer-events: none;
display: flex;
align-items: center;
gap: 10px;
border: 1px solid rgba(255,255,255,0.06);
}
.edu-toast.error {
background: rgba(60, 20, 30, 0.92);
border-color: rgba(255, 80, 80, 0.2);
}
.edu-toast.success {
background: rgba(20, 50, 40, 0.92);
border-color: rgba(80, 255, 150, 0.2);
}
.edu-toast.hide {
animation: fadeSlideOut 0.3s ease forwards;
}
`;
document.head.appendChild(style);
// ---------- 工具函数 ----------
function getContentId() {
const params = new URLSearchParams(window.location.search);
const id = params.get('contentId');
if (!id || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
return null;
}
return id;
}
async function fetchAccessToken() {
const MAX_RETRIES = 3;
const DELAY = 500;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const keys = Object.keys(localStorage).filter(k =>
k.includes('ND_UC_AUTH') || k.includes('nd_uc_auth')
);
if (keys.length === 0) {
await sleep(DELAY);
continue;
}
for (const key of keys) {
const raw = localStorage.getItem(key);
if (!raw) continue;
let data;
try { data = JSON.parse(raw); } catch { continue; }
if (!data.value) continue;
let parsed;
try { parsed = JSON.parse(data.value); } catch { parsed = data.value; }
if (parsed?.access_token) {
return parsed.access_token;
}
}
await sleep(DELAY);
}
return null;
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
function showToast(message, type = 'info', duration = 3000) {
const existing = document.querySelector('.edu-toast');
if (existing) {
existing.classList.add('hide');
setTimeout(() => existing.remove(), 350);
}
const toast = document.createElement('div');
toast.className = `edu-toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('hide');
setTimeout(() => toast.remove(), 350);
}, duration);
}
// ---------- 核心下载 ----------
async function handleDownload(btn) {
if (btn.classList.contains('loading')) return;
btn.classList.add('loading');
try {
const contentId = getContentId();
if (!contentId) {
showToast('当前页面没有有效的教材ID', 'error');
btn.classList.remove('loading');
return;
}
const token = await fetchAccessToken();
if (!token) {
showToast('未获取到登录令牌,请刷新页面重试', 'error');
btn.classList.remove('loading');
return;
}
const url = `https://s-file-2.ykt.cbern.com.cn/zxx/ndrv2/resources/tch_material/details/${contentId}.json`;
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error('请求超时'))
});
});
if (response.status !== 200) {
throw new Error(`API请求失败 (${response.status})`);
}
const data = JSON.parse(response.responseText);
const source = data.ti_items?.find(item => item.ti_file_flag === 'source');
if (!source || !source.ti_storages?.length) {
throw new Error('未找到教材存储信息');
}
const parts = source.ti_storages.flatMap(s =>
s.split('`').map(p => p.replace(/\s+/g, ' ').trim()).filter(Boolean)
);
let current = '';
const candidates = [];
for (const p of parts) {
if (p.startsWith('http')) {
if (current) candidates.push(current);
current = p;
} else {
current += p;
}
}
if (current) candidates.push(current);
const pdfUrl = candidates.find(u => u.toLowerCase().endsWith('.pdf'));
if (!pdfUrl) {
throw new Error('未找到PDF下载链接');
}
const downloadUrl = `${pdfUrl}?accessToken=${token}`;
GM_openInTab(downloadUrl, { active: true });
showToast('✅ 下载已在新标签页开始', 'success');
} catch (err) {
console.error('下载失败:', err);
showToast(`❌ ${err.message || '未知错误'}`, 'error');
} finally {
btn.classList.remove('loading');
}
}
// ---------- 创建可拖拽按钮(使用 pointer 事件,修复错位) ----------
function createDraggableButton() {
const btn = document.createElement('button');
btn.className = 'edu-download-btn';
btn.setAttribute('title', '点击下载教材 (快捷键 M)');
btn.innerHTML = `
📘
教材下载
`;
document.body.appendChild(btn);
// 恢复保存的位置
const savedPos = GM_getValue('eduBtnPos');
if (savedPos) {
btn.style.left = savedPos.left || '30px';
btn.style.top = savedPos.top || '30px';
btn.style.bottom = '';
btn.style.right = '';
}
let startX = 0, startY = 0;
let offsetX = 0, offsetY = 0; // 指针相对于按钮左上角的偏移
let isDragging = false;
let wasDragged = false;
const DRAG_THRESHOLD = 5;
// ---------- pointer 事件 ----------
function onPointerDown(e) {
// 只响应鼠标左键或触摸(pointer 自动处理)
if (e.button !== undefined && e.button !== 0) return;
const rect = btn.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
startX = e.clientX;
startY = e.clientY;
wasDragged = false;
isDragging = true;
btn.style.cursor = 'grabbing';
btn.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onPointerMove(e) {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!wasDragged && Math.sqrt(dx*dx + dy*dy) > DRAG_THRESHOLD) {
wasDragged = true;
}
if (wasDragged) {
let newLeft = e.clientX - offsetX;
let newTop = e.clientY - offsetY;
// 边界限制
const maxX = window.innerWidth - btn.offsetWidth;
const maxY = window.innerHeight - btn.offsetHeight;
newLeft = Math.max(0, Math.min(newLeft, maxX));
newTop = Math.max(0, Math.min(newTop, maxY));
btn.style.left = newLeft + 'px';
btn.style.top = newTop + 'px';
btn.style.bottom = '';
btn.style.right = '';
}
}
function onPointerUp(e) {
if (!isDragging) return;
isDragging = false;
btn.style.cursor = 'grab';
// 判断是拖拽还是点击
if (wasDragged) {
// 保存位置
GM_setValue('eduBtnPos', {
left: btn.style.left,
top: btn.style.top
});
} else {
// 点击触发下载
handleDownload(btn);
}
wasDragged = false;
}
function onPointerCancel(e) {
if (isDragging) {
isDragging = false;
btn.style.cursor = 'grab';
wasDragged = false;
}
}
// 注册事件
btn.addEventListener('pointerdown', onPointerDown);
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerCancel);
// ---------- 键盘快捷键 ----------
document.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 'm' &&
!['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName) &&
!e.target.isContentEditable) {
e.preventDefault();
handleDownload(btn);
}
});
// 初始光标
btn.style.cursor = 'grab';
// 欢迎提示
setTimeout(() => {
showToast('🤖 点击“教材下载”按钮获取教材(可拖拽)', 'info', 2800);
}, 1000);
return btn;
}
createDraggableButton();
})();