// ==UserScript== // @name 学科网自动下载 · 猫爪脚本 // @name:zh-CN 学科网自动下载 · 猫爪脚本 // @namespace https://github.com/qoderwork/zxxk-autodownload // @version 1.1.0 // @description 在学科网 / AI职教(*.xkw.com、*.zxxk.com)的打包页、资源篮、资源详情页、列表页采集资料链接,形成下载队列,批量提交到“学科资料下载”服务,自动下载文件或标记“已发送到邮箱”。含配置面板、下载按钮、进度与日志。 // @author QoderWork // @match *://*.xkw.com/* // @match *://*.zxxk.com/* // @match *://xkw.com/* // @match *://zxxk.com/* // @run-at document-idle // @noframes // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @connect * // @license MIT // ==/UserScript== (function () { "use strict"; /* ============ 存储与默认配置 ============ */ var PREFIX = "zxxk_dl_"; function sget(k, def) { try { var v = GM_getValue(PREFIX + k); return v === undefined ? def : v; } catch (e) { return def; } } function sset(k, v) { try { GM_setValue(PREFIX + k, v); } catch (e) {} } var DEFAULTS = { server: "", // 下载服务基础地址(末尾不带斜杠) username: "", password: "", email: "", delay: 4, // 每条之间等待秒数,避免触发当日/当周上限 autoDownload: true // true=直接存盘;false=仅列出直链 }; var CFG = { server: sget("server", DEFAULTS.server).replace(/\/+$/, ""), username: sget("username", DEFAULTS.username), password: sget("password", DEFAULTS.password), email: sget("email", DEFAULTS.email), delay: parseFloat(sget("delay", DEFAULTS.delay)) || DEFAULTS.delay, autoDownload: sget("autoDownload", DEFAULTS.autoDownload) !== false }; /* ============ 下载服务响应码 → 文案 ============ */ var CODE_MSG = { "111": "账号或者密码输入错误", "222": "当天下载份数达到上限,请明天再试", "555": "本周下载份数达到上限,请下周一再试", "666": "当前材料为第三方,无权下载", "777": "本周剩余份数不足以下载本书", "888": "网址输入错误 / 无法解析", "1010": "出现异常,请刷新重试", "1020": "邮箱输入错误", "1030": "多个网址请分批次提交,请勿一次性提交", "1040": "当前账号未激活,请联系客服", "1050": "份数已全部用完,请重新续费", "1060": "获取下载记录异常,请重试", "1070": "当前账号已到期,请重新续费", "1080": "剩余份数不足以下载当前材料", "1090": "该网址已提交过,请勿重复提交" }; /* ============ 队列数据 ============ */ // item: { url, title, status, link, msg } var queue = sget("queue", []); try { if (typeof queue === "string") queue = JSON.parse(queue); } catch (e) { queue = []; } if (!Array.isArray(queue)) queue = []; var running = false; var stopFlag = false; /* ============ 工具函数 ============ */ function esc(s) { return String(s == null ? "" : s).replace( /[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; } ); } // 从一行响应文本解析: code + rest(首个冒号后全部) function parseResp(text) { var d = (text || "").trim(); var i = d.indexOf(":"); if (i < 0) return { code: d, rest: "" }; return { code: d.substring(0, i), rest: d.substring(i + 1) }; } // 把 rest 拆成 name 与 payload(以第一个冒号分隔) function splitRest(rest) { var i = rest.indexOf(":"); if (i < 0) return { name: rest.trim(), payload: "" }; return { name: rest.substring(0, i).trim(), payload: rest.substring(i + 1).trim() }; } function safeName(s, fallback) { var n = (s || "").trim().replace(/[\\/:*?"<>|\r\n]+/g, "_").replace(/\s+/g, " "); return n || fallback || "zxxk-file"; } function log(msg) { var el = document.getElementById("zxxk-log"); if (!el) return; var t = new Date().toLocaleTimeString("zh-CN", { hour12: false }); var line = document.createElement("div"); line.className = "zxxk-log-line"; line.textContent = "[" + t + "] " + msg; el.appendChild(line); while (el.childNodes.length > 400) el.removeChild(el.firstChild); el.scrollTop = el.scrollHeight; } /* ============ GM_xmlhttpRequest 封装 ============ */ function gmPost(url, body) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: "POST", url: url, headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, data: body, timeout: 120000, onload: function (r) { resolve(r.responseText || ""); }, onerror: function (e) { reject(new Error("网络请求失败")); }, ontimeout: function () { reject(new Error("请求超时")); } }); }); } function gmGetBlob(url, extraHeaders) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: "GET", url: url, responseType: "arraybuffer", headers: Object.assign({ Referer: location.href }, extraHeaders || {}), timeout: 300000, onload: function (r) { resolve(r); }, onerror: function () { reject(new Error("文件下载失败")); }, ontimeout: function () { reject(new Error("文件下载超时")); } }); }); } // 从响应头解析文件名 function filenameFromHeaders(headers) { if (!headers) return ""; var m = /filename\*?=\s*(?:UTF-8'')?["']?([^"';\n]+)["']?/i.exec(headers); if (m) { try { return decodeURIComponent(m[1].trim()); } catch (e) { return m[1].trim(); } } return ""; } function saveBlob(arrayBuffer, filename) { var blob = new Blob([arrayBuffer]); var objUrl = URL.createObjectURL(blob); var a = document.createElement("a"); a.href = objUrl; a.download = safeName(filename, "zxxk-" + Date.now()); document.body.appendChild(a); a.click(); setTimeout(function () { document.body.removeChild(a); URL.revokeObjectURL(objUrl); }, 2000); } // 下载直链文件(444 / 4443) function downloadDirect(fileUrl, title) { return gmGetBlob(fileUrl).then(function (r) { var name = filenameFromHeaders(r.responseHeaders) || safeName(title, "zxxk-" + Date.now()); saveBlob(r.response, name); return name; }); } // 教材下载(4442):向 DownloadJiaofu 提交 path 换取文件 function downloadJiaofu(path, name) { var url = CFG.server + "/DownloadJiaofu"; return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: "POST", url: url, headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, data: "path=" + encodeURIComponent(encodeURI(path)), responseType: "arraybuffer", timeout: 300000, onload: function (r) { var fname = filenameFromHeaders(r.responseHeaders) || name || (function () { var b = path.lastIndexOf("\\"); return b >= 0 ? path.substring(b + 1) : path; })(); saveBlob(r.response, fname); resolve(fname); }, onerror: function () { reject(new Error("教材下载失败")); }, ontimeout: function () { reject(new Error("教材下载超时")); } }); }); } /* ============ 单条提交流程 ============ */ function submitOne(item) { var body = "username=" + CFG.username + "&password=" + CFG.password + "&zxxkurl=" + item.url + "&usermail=" + CFG.email; return gmPost(CFG.server + "/zxxkdown", body).then(function (text) { var p = parseResp(text); var code = p.code, rest = p.rest; if (code === "444" || code === "4443") { var s = splitRest(rest); // 兼容无标题的情况:若 payload 以 "//" 开头,说明第一个冒号其实是协议里的, // 整个 rest 才是链接、标题为空。 if (/^\/\//.test(s.payload)) { s = { name: "", payload: rest }; } item.link = s.payload; if (CFG.autoDownload && s.payload) { return downloadDirect(s.payload, s.name || item.title) .then(function (fname) { item.status = "已下载"; item.msg = fname; }) .catch(function (err) { item.status = "下载失败"; item.msg = (err && err.message) || "未知错误"; }); } else { item.status = "已解析"; item.msg = "已生成直链,点击“打开”下载"; } return; } if (code === "4442") { var s2 = splitRest(rest); if (CFG.autoDownload && s2.payload) { return downloadJiaofu(s2.payload, s2.name) .then(function (fname) { item.status = "已下载"; item.msg = fname; }) .catch(function (err) { item.status = "下载失败"; item.msg = (err && err.message) || "未知错误"; }); } else { item.status = "已解析"; item.msg = "教材待下载:" + s2.payload; } return; } if (code === "333" || code === "9333") { var s3 = splitRest(rest); item.status = "已发邮箱"; item.msg = "材料将发送到邮箱,请 10 分钟后查收" + (s3.name ? "(" + s3.name + ")" : ""); return; } // 其它为错误码 item.status = "失败"; item.msg = CODE_MSG[code] || rest || ("未知响应:" + (text || "").slice(0, 60)); return; }); } /* ============ 队列批处理 ============ */ function processQueue() { if (running) return; var todo = queue.filter(function (it) { return it.checked !== false && (it.status === "待下载" || it.status === "重试"); }); if (!todo.length) { log("没有待下载的条目(先“采集”或勾选条目再点“开始下载”)。"); return; } if (!CFG.username || !CFG.password) { log("请先在“配置”里填写账号与密码。"); openConfig(); return; } running = true; stopFlag = false; setRunningUI(true); log("开始下载,共 " + todo.length + " 条,间隔 " + CFG.delay + " 秒。"); var chain = Promise.resolve(); todo.forEach(function (item) { chain = chain.then(function () { if (stopFlag) return; item.status = "解析中"; item.msg = ""; renderList(); return submitOne(item) .catch(function (err) { item.status = "失败"; item.msg = (err && err.message) || "请求失败"; }) .then(function () { log(item.url + " → " + item.status + (item.msg ? ":" + item.msg : "")); renderList(); if (!stopFlag) return sleep(CFG.delay * 1000); }); }); }); chain.then(function () { running = false; setRunningUI(false); log(stopFlag ? "已停止。" : "全部处理完成。"); updateProgress(); }); } function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } /* ============ 采集当前页链接 ============ */ function collectLinks() { var found = 0; var seen = {}; queue.forEach(function (it) { seen[it.url] = 1; }); var anchors = document.querySelectorAll('a[href*="/doc/"]'); anchors.forEach(function (a) { var href = a.href || ""; var m = /\/doc\/(\d+)\.html?/i.exec(href); if (!m) return; var url = href.split("#")[0]; var title = (a.textContent || "").trim() || (a.getAttribute("title") || "").trim() || (a.querySelector("img") && (a.querySelector("img").alt || "")) || "资料 " + m[1]; if (seen[url]) return; seen[url] = 1; queue.push({ url: url, title: title.slice(0, 80), status: "待下载", link: "", msg: "", checked: true }); found++; }); // 详情页:把当前页自身也加入 if (/\/doc\/\d+\.html?/i.test(location.pathname)) { var selfUrl = location.origin + location.pathname; if (!seen[selfUrl]) { queue.push({ url: selfUrl, title: (document.title || "当前资料").trim().slice(0, 80), status: "待下载", link: "", msg: "", checked: true }); found++; } } sset("queue", JSON.stringify(queue)); renderList(); if (found === 0) { log("当前页面未发现可采集的资料链接(/doc/xxxx.html)。请在打包页、资源篮页、列表页或资源详情页操作。"); } else { log("采集到 " + found + " 条新链接,队列共 " + queue.length + " 条。"); } } /* ============ UI ============ */ function buildUI() { GM_addStyle( [ "#zxxk-fab{position:fixed;right:18px;bottom:18px;z-index:2147483646;width:52px;height:52px;border-radius:50%;", "background:linear-gradient(135deg,#ff5f6d,#ffc371);color:#fff;font-size:22px;line-height:52px;text-align:center;", "cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.28);user-select:none;font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;}", "#zxxk-fab:hover{transform:translateY(-2px);}", "#zxxk-panel{position:fixed;right:18px;bottom:80px;z-index:2147483646;width:440px;max-height:78vh;display:none;", "flex-direction:column;background:#fff;color:#222;border-radius:12px;overflow:hidden;font-size:13px;", "box-shadow:0 12px 40px rgba(0,0,0,.28);font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;border:1px solid #eee;}", "#zxxk-panel.open{display:flex;}", ".zxxk-hd{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:#2b3a55;color:#fff;}", ".zxxk-hd b{font-size:14px;}", ".zxxk-hd .zxxk-hd-btns span{margin-left:12px;cursor:pointer;opacity:.9;}", ".zxxk-body{overflow:auto;padding:10px 12px;}", ".zxxk-cfg{display:none;background:#f6f8fb;border:1px solid #e6ebf2;border-radius:8px;padding:10px;margin-bottom:10px;}", ".zxxk-cfg.open{display:block;}", ".zxxk-row{display:flex;align-items:center;margin:6px 0;gap:8px;}", ".zxxk-row label{width:64px;color:#555;flex:none;}", ".zxxk-row input[type=text],.zxxk-row input[type=password]{flex:1;padding:6px 8px;border:1px solid #d4dbe6;border-radius:6px;box-sizing:border-box;font-size:13px;}", ".zxxk-row input[type=number]{width:80px;padding:6px;border:1px solid #d4dbe6;border-radius:6px;}", ".zxxk-inline{display:flex;align-items:center;gap:6px;color:#555;}", ".zxxk-tools{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;}", ".zxxk-list{border:1px solid #eee;border-radius:8px;max-height:220px;overflow:auto;}", ".zxxk-item{display:flex;align-items:center;gap:8px;padding:7px 8px;border-bottom:1px solid #f2f2f2;}", ".zxxk-item:last-child{border-bottom:none;}", ".zxxk-item .ttl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#333;}", ".zxxk-item .st{flex:none;font-size:12px;padding:2px 7px;border-radius:10px;background:#eef1f5;color:#667;}", ".zxxk-item .st.ok{background:#e6f6ec;color:#1d8348;}", ".zxxk-item .st.mail{background:#fff3e0;color:#b26a00;}", ".zxxk-item .st.err{background:#fdecea;color:#c0392b;}", ".zxxk-item .st.run{background:#e8f0fe;color:#1a56db;}", ".zxxk-item .op{flex:none;color:#1a73e8;cursor:pointer;margin-left:4px;}", ".zxxk-ft{display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid #eee;background:#fafbfc;}", "button.zxxk-btn{border:none;border-radius:6px;padding:8px 14px;font-size:13px;cursor:pointer;background:#2b3a55;color:#fff;}", "button.zxxk-btn.ghost{background:#eef1f5;color:#334;}", "button.zxxk-btn.dl{background:linear-gradient(135deg,#ff5f6d,#ff8a5f);}", "button.zxxk-btn:disabled{opacity:.5;cursor:not-allowed;}", ".zxxk-log{margin-top:8px;border:1px solid #eee;border-radius:8px;background:#0f1720;color:#b9c6d6;font-size:11px;", "line-height:1.5;padding:8px;height:110px;overflow:auto;font-family:Menlo,Consolas,monospace;}", ".zxxk-prog{margin-left:auto;color:#666;font-size:12px;}", ".zxxk-empty{padding:18px;text-align:center;color:#aaa;}" ].join("") ); var fab = document.createElement("div"); fab.id = "zxxk-fab"; fab.title = "学科网自动下载"; fab.textContent = "🐾"; document.body.appendChild(fab); var panel = document.createElement("div"); panel.id = "zxxk-panel"; panel.innerHTML = '