// ==UserScript== // @name 学习通签到 // @namespace drip-chaoxing-signin-prototype // @version 0.4.0 // @author drip7boi // @description 独立设置面板原型:自动签到由云服务器执行 // @match *://*.chaoxing.com/* // @run-at document-end // @grant GM_getValue // @grant GM_setValue // ==/UserScript== (function () { "use strict"; const API = { courseList: "https://mooc1-1.chaoxing.com/visit/courselistdata", activeList: "https://mobilelearn.chaoxing.com/v2/apis/active/student/activelist", activeInfo: "https://mobilelearn.chaoxing.com/v2/apis/active/getPPTActiveInfo", preSign: "https://mobilelearn.chaoxing.com/newsign/preSign", sign: "https://mobilelearn.chaoxing.com/pptSign/stuSignajax", im: "https://im.chaoxing.com/webim/me", userInfo: "https://passport2-api.chaoxing.com/v11/getuserinfo?cx_xxt_passport=json" }; const STORE = { interval: "drip.signin.interval", lat: "drip.signin.lat", lon: "drip.signin.lon", address: "drip.signin.address" }; const state = { user: null, courses: [], activities: [], timer: null, imConn: null, imListening: false, signedIds: new Set() }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const nowText = () => new Date().toLocaleTimeString(); const enc = (value) => encodeURIComponent(value == null ? "" : String(value)); function request(url, options = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: options.method || "GET", url, data: options.data, headers: options.headers || {}, anonymous: false, timeout: options.timeout || 30000, onload: (res) => resolve(res), ontimeout: () => reject(new Error("请求超时")), onerror: () => reject(new Error("网络请求失败")) }); }); } async function requestJson(url, options = {}) { const res = await request(url, options); try { return JSON.parse(res.responseText || "{}"); } catch (error) { throw new Error(`返回不是 JSON:${(res.responseText || "").slice(0, 80)}`); } } function getCookie(name) { const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : ""; } function extractUserFromPage() { const dripUuid = document.querySelector(".cxds-quick-row-id em")?.textContent?.trim(); const candidates = [ getCookie("_uid"), getCookie("UID"), getCookie("puid"), dripUuid ].filter(Boolean); const uid = candidates.find((value) => /^\d+$/.test(String(value))); const fid = getCookie("fid") || "-1"; return uid ? { uid: String(uid), fid: String(fid || "-1"), name: "" } : null; } function extractUserFromPayload(payload) { const data = payload?.data || payload?.msg || payload?.result || payload || {}; const uid = data.uid || data._uid || data.puid || data.userId || data.id || getCookie("_uid") || getCookie("UID"); const fid = data.fid || data.schoolid || data.schoolId || getCookie("fid") || "-1"; const name = data.name || data.realName || data.uname || data.nick || ""; return uid ? { uid: String(uid), fid: String(fid || "-1"), name: String(name || "") } : null; } async function getCurrentUser() { let user = extractUserFromPage(); if (!user) { const payload = await requestJson(API.userInfo); user = extractUserFromPayload(payload); } if (!user) throw new Error("未识别到学习通登录态,请先登录学习通后刷新页面"); state.user = user; return user; } async function getCourses() { const res = await request(API.courseList, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, data: "courseType=1&courseFolderId=0&courseFolderSize=0" }); if (res.status === 302 || /login|passport2/i.test(res.finalUrl || "")) { throw new Error("课程接口要求重新登录学习通"); } const html = res.responseText || ""; const seen = new Set(); const courses = []; const regex = /course_(\d+)_(\d+)/g; let match; while ((match = regex.exec(html))) { const key = `${match[1]}_${match[2]}`; if (seen.has(key)) continue; seen.add(key); courses.push({ courseId: match[1], classId: match[2] }); } state.courses = courses; return courses; } function normalizeActivity(raw, course) { const otherId = Number(raw.otherId); return { id: String(raw.id), name: raw.nameOne || raw.nameFour || raw.nameTwo || "未命名签到", courseId: course.courseId, classId: course.classId, otherId, status: Number(raw.status), startTime: Number(raw.startTime || 0) }; } function isSupportedActivity(activity) { if (!Number.isFinite(activity.otherId)) return false; if (activity.status !== 1) return false; if (activity.otherId < 0 || activity.otherId > 5) return false; if (activity.startTime && Date.now() - activity.startTime > 2 * 60 * 60 * 1000) return false; return true; } async function getActivitiesForCourse(course) { const url = `${API.activeList}?fid=0&courseId=${enc(course.courseId)}&classId=${enc(course.classId)}&_=${Date.now()}`; const payload = await requestJson(url); const list = payload?.data?.activeList || []; return list.map((item) => normalizeActivity(item, course)).filter(isSupportedActivity); } async function detectActivities() { const courses = state.courses.length ? state.courses : await getCourses(); const found = []; for (let i = 0; i < courses.length; i += 1) { try { found.push(...await getActivitiesForCourse(courses[i])); } catch (error) { log(`课程 ${i + 1} 检测失败:${error.message}`, "warn"); } if (i % 5 === 4) await sleep(500); } state.activities = found; renderActivities(); return found; } async function getActivityInfo(activity) { const payload = await requestJson(`${API.activeInfo}?activeId=${enc(activity.id)}`); return payload?.data || {}; } async function preSign(activity) { const user = state.user || await getCurrentUser(); const url = `${API.preSign}?courseId=${enc(activity.courseId)}&classId=${enc(activity.classId)}&activePrimaryId=${enc(activity.id)}&general=1&sys=1&ls=1&appType=15&tid=&uid=${enc(user.uid)}&ut=s`; await request(url); } function buildSignUrl(activity, info) { const user = state.user; const fid = user?.fid || "-1"; const name = info?.name || state.user?.name || ""; const type = Number(info?.otherId ?? activity.otherId); const lat = GM_getValue(STORE.lat, ""); const lon = GM_getValue(STORE.lon, ""); const address = GM_getValue(STORE.address, ""); const isLocation = type === 4; const params = [ `activeId=${enc(activity.id)}`, `uid=${enc(user.uid)}`, "clientip=", `latitude=${enc(isLocation ? lat : "-1")}`, `longitude=${enc(isLocation ? lon : "-1")}`, "appType=15", `fid=${enc(fid)}`, `name=${enc(name)}` ]; if (isLocation) { params.push(`address=${enc(address)}`); params.push("ifTiJiao=1"); } return `${API.sign}?${params.join("&")}`; } function signTypeName(otherId) { switch (Number(otherId)) { case 0: return "普通/拍照"; case 2: return "二维码"; case 3: return "手势"; case 4: return "位置"; case 5: return "签到码"; default: return "未知"; } } async function signActivity(activity) { if (!activity) throw new Error("没有可签到活动"); if (!state.user) await getCurrentUser(); const info = await getActivityInfo(activity); const type = Number(info?.otherId ?? activity.otherId); if (type === 2) { throw new Error(`${signTypeName(type)}签到第一版先不自动提交`); } if (type === 0 && Number(info?.ifphoto) === 1) { throw new Error("拍照签到第一版先不自动提交"); } if (type === 4 && (!GM_getValue(STORE.lat, "") || !GM_getValue(STORE.lon, "") || !GM_getValue(STORE.address, ""))) { throw new Error("位置签到需要先填写经纬度和地址"); } await preSign(activity).catch(() => {}); const res = await request(buildSignUrl(activity, info)); const text = (res.responseText || "").trim(); if (text === "success") return "签到成功"; return text || `未知结果 HTTP ${res.status}`; } function readHiddenText(html, id) { const match = String(html).match(new RegExp(`id=["']${id}["'][^>]*>([^<]*)<`, "i")); return match ? match[1].trim() : ""; } async function getIMParams() { const res = await request(API.im); const html = res.responseText || ""; const params = { myName: readHiddenText(html, "myName"), myToken: readHiddenText(html, "myToken"), myTuid: readHiddenText(html, "myTuid"), myPuid: getCookie("_uid") || getCookie("UID") || "" }; if (!params.myToken || !params.myTuid) { throw new Error("获取 IM 登录态失败,请确认学习通网页已登录"); } return params; } function getWebIM() { const sdk = window.websdk || (typeof unsafeWindow !== "undefined" ? unsafeWindow.websdk : null); const webIM = sdk?.default || sdk; if (!webIM?.connection) throw new Error("WebIM SDK 未加载,脚本猫可能拦截了 @require"); return webIM; } async function signFromIMMessage(message) { const course = message?.ext?.attachment?.att_chat_course; if (!course?.url?.includes("sign")) return false; const activity = { id: String(course.aid || ""), name: "IM 推送签到", courseId: String(course?.courseInfo?.courseid || ""), classId: String(course?.courseInfo?.classid || ""), otherId: 0, status: 1, startTime: Date.now() }; if (!activity.id || !activity.courseId || !activity.classId) { log("IM 检测到群聊/无课程签到,原型暂不处理", "warn"); return true; } if (state.signedIds.has(activity.id)) return true; const info = await getActivityInfo(activity); activity.otherId = Number(info?.otherId ?? 0); state.activities = [activity, ...state.activities.filter((item) => item.id !== activity.id)]; renderActivities(); log(`IM 检测到${signTypeName(activity.otherId)}:${activity.id}`, "success"); const result = await signActivity(activity); state.signedIds.add(activity.id); log(`IM 自动提交:${result}`, result.includes("成功") ? "success" : "warn"); setStatus(result.includes("成功") ? "IM 签到成功" : "IM 已返回结果", result.includes("成功") ? "ready" : "warn"); return true; } async function startIMListen() { if (state.imListening) return; if (!state.user) await getCurrentUser(); const webIM = getWebIM(); const params = await getIMParams(); const conn = new webIM.connection({ isMultiLoginSessions: true, https: true, url: "https://im-api-vip6-v2.easecdn.com/ws", apiUrl: "https://a1-vip6.easecdn.com", isAutoLogin: true, heartBeatWait: 4500, autoReconnectNumMax: 2, autoReconnectInterval: 2, appKey: "cx-dev#cxstudy", isHttpDNS: false }); state.imConn = conn; conn.listen({ onOpened: () => { state.imListening = true; setStatus("IM 监听中", "ready"); log(`IM 已连接:${params.myName || params.myTuid}`, "success"); }, onClosed: () => { state.imListening = false; setStatus("IM 已断开", "warn"); log("IM 监听已断开,15 秒轮询仍会继续", "warn"); }, onTextMessage: async (message) => { try { await signFromIMMessage(message); } catch (error) { setStatus("IM 签到失败", "error"); log(error.message || String(error), "error"); } }, onError: (error) => { state.imListening = false; setStatus("IM 异常", "error"); log(`IM 异常:${error?.message || JSON.stringify(error) || error}`, "error"); } }); conn.open({ apiUrl: "https://a1-vip6.easecdn.com", user: params.myTuid, accessToken: params.myToken, appKey: "cx-dev#cxstudy" }); } function stopIMListen() { try { state.imConn?.close?.(); } catch (error) { log(`关闭 IM 失败:${error.message || error}`, "warn"); } state.imConn = null; state.imListening = false; } function log(message, level = "info") { const box = document.querySelector("#drip-signin-logs"); if (!box) return; const item = document.createElement("div"); item.className = `drip-signin-log is-${level}`; item.textContent = `[${nowText()}] ${message}`; box.prepend(item); } function setStatus(text, level = "ready") { const node = document.querySelector("#drip-signin-status"); if (!node) return; node.textContent = text; node.className = `drip-signin-status is-${level}`; } function renderActivities() { const box = document.querySelector("#drip-signin-activities"); if (!box) return; if (!state.activities.length) { box.innerHTML = `
暂未检测到进行中的签到
`; return; } box.innerHTML = state.activities.map((item, index) => ` `).join(""); } function getSelectedActivity() { const checked = document.querySelector('input[name="drip-signin-active"]:checked'); const index = checked ? Number(checked.value) : 0; return state.activities[index] || null; } function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); } async function runDetect() { setStatus("服务器执行", "ready"); log("前台检测已禁用,签到检测由云服务器执行。", "warn"); } async function runSign() { setStatus("服务器执行", "ready"); log("前台签到已禁用,自动签到只允许云服务器执行。", "warn"); } async function autoDetectAndSign() { setStatus("服务器执行", "ready"); log("浏览器自动签到已禁用。", "warn"); } function toggleAuto() { setStatus("服务器执行", "ready"); log("前台不提供自动签到开关,后续合并 Drip 时改为服务器任务开关。", "warn"); } function saveSettings() { GM_setValue(STORE.lat, document.querySelector("#drip-signin-lat").value || ""); GM_setValue(STORE.lon, document.querySelector("#drip-signin-lon").value || ""); GM_setValue(STORE.address, document.querySelector("#drip-signin-address").value || ""); log("设置已保存到本地。服务器同步接口接入后会自动下发。", "success"); } function injectStyle() { const style = document.createElement("style"); style.textContent = ` #drip-signin-logo{position:fixed;right:24px;bottom:120px;z-index:2147483646;width:48px;height:48px;border:0;border-radius:16px;background:#111827;color:#fff;box-shadow:0 16px 40px rgba(15,23,42,.28);font-weight:800;cursor:pointer} #drip-signin-panel{position:fixed;right:84px;bottom:92px;z-index:2147483646;width:420px;max-width:calc(100vw - 28px);background:#f8fafc;color:#111827;border:1px solid rgba(148,163,184,.35);border-radius:8px;box-shadow:0 24px 70px rgba(15,23,42,.32);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden} #drip-signin-panel.is-hidden{display:none} .drip-signin-head{display:flex;align-items:center;gap:10px;padding:14px 16px;background:#111827;color:#fff} .drip-signin-head strong{font-size:16px}.drip-signin-head span{display:block;color:#cbd5e1;font-size:12px}.drip-signin-close{margin-left:auto;border:0;background:transparent;color:#fff;font-size:20px;cursor:pointer} .drip-signin-body{padding:14px;display:grid;gap:12px}.drip-signin-status{display:inline-flex;align-items:center;width:max-content;padding:4px 9px;border-radius:999px;font-size:12px;font-weight:700;background:#e2e8f0;color:#334155} .drip-signin-status.is-running{background:#dbeafe;color:#1d4ed8}.drip-signin-status.is-ready{background:#dcfce7;color:#15803d}.drip-signin-status.is-warn{background:#fef3c7;color:#a16207}.drip-signin-status.is-error{background:#fee2e2;color:#b91c1c} .drip-signin-card{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:12px}.drip-signin-card-title{font-weight:800;margin-bottom:10px;color:#0f172a} .drip-signin-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.drip-signin-field{display:grid;gap:4px}.drip-signin-field span{font-size:12px;color:#64748b}.drip-signin-field input{height:34px;border:1px solid #cbd5e1;border-radius:6px;padding:0 9px} .drip-signin-actions{display:grid;grid-template-columns:1fr;gap:8px}.drip-signin-actions button,.drip-signin-save{height:36px;border:0;border-radius:6px;background:#2563eb;color:#fff;font-weight:700;cursor:pointer}.drip-signin-save{background:#475569} #drip-signin-activities{display:grid;gap:8px;max-height:180px;overflow:auto}.drip-signin-activity{display:flex;gap:8px;padding:8px;border:1px solid #e2e8f0;border-radius:6px;cursor:pointer}.drip-signin-activity strong{display:block}.drip-signin-activity em{display:block;color:#64748b;font-style:normal;font-size:12px}.drip-signin-empty{color:#64748b}.drip-signin-server-row{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid #e5e7eb}.drip-signin-server-row:last-child{border-bottom:0}.drip-signin-server-row span{color:#64748b}.drip-signin-server-row strong{color:#0f172a;text-align:right} #drip-signin-logs{height:150px;overflow:auto;background:#0f172a;color:#cbd5e1;border-radius:6px;padding:8px;font-size:12px}.drip-signin-log{padding:3px 0;border-bottom:1px solid rgba(148,163,184,.18)}.drip-signin-log.is-success{color:#86efac}.drip-signin-log.is-warn{color:#fde68a}.drip-signin-log.is-error{color:#fca5a5} `; document.head.appendChild(style); } function mount() { if (window.top !== window || document.getElementById("drip-signin-panel")) return; injectStyle(); const logo = document.createElement("button"); logo.id = "drip-signin-logo"; logo.type = "button"; logo.textContent = "签"; document.body.appendChild(logo); const panel = document.createElement("section"); panel.id = "drip-signin-panel"; panel.innerHTML = `
drip 签到面板独立测试版 v0.4 · 服务器模式
服务器执行
`; document.body.appendChild(panel); logo.addEventListener("click", () => panel.classList.toggle("is-hidden")); panel.querySelector(".drip-signin-close").addEventListener("click", () => panel.classList.add("is-hidden")); panel.querySelector('[data-drip-signin-action="save"]').addEventListener("click", saveSettings); log("服务器面板已加载。前台不执行自动签到。", "success"); } if (/chaoxing\.com/i.test(location.hostname)) { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", mount, { once: true }); } else { mount(); } } })();