// ==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 = `