#!/usr/bin/env node /** * 莞易学(study.dgjy.net)学习链路的 axios 实现。 * * 只做三件事:查进度 / 课时上报 / 完成后回读复核。 * 不负责登录(Middle-Token 从浏览器 cookie 里复制),也不假装能绕过服务端校验。 * * 用法: * MIDDLE_TOKEN='' \ * node js/study-axios.mjs status 123456 * * MIDDLE_TOKEN=... node js/study-axios.mjs watch 123456 * MIDDLE_TOKEN=... node js/study-axios.mjs watch 123456 --beat 2 --limit 900 * * 依赖:axios(npm i axios),Node >= 18。 */ import axios from 'axios'; const BASE = process.env.DGJY_BASE || 'https://study.dgjy.net/train'; const TOKEN = process.env.MIDDLE_TOKEN || ''; if (!TOKEN) { console.error('缺少 MIDDLE_TOKEN(浏览器 F12 → Application → Cookies → study.dgjy.net → Middle-Token)'); process.exit(1); } const http = axios.create({ baseURL: BASE, timeout: 20000, headers: { 'Content-Type': 'application/json;charset=utf-8', Authorization: `Bearer ${TOKEN}`, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36', Referer: 'https://study.dgjy.net/training/', }, }); // 站点统一返回 {code, msg, data},HTTP 200 也可能是业务失败 http.interceptors.response.use( (r) => { const b = r.data; if (!b || typeof b !== 'object') throw new Error(`${r.config.url} → 非 JSON 响应`); if (b.code !== 200) throw new Error(`${r.config.url} → code=${b.code} ${b.msg || b.message || ''}`); return b.data; }, (e) => { const u = e.config ? `${e.config.method} ${e.config.url}` : ''; const s = e.response ? `HTTP ${e.response.status}` : e.code || e.message; throw new Error(`${u} → ${s}`); }, ); // ---------------------------------------------------------------- 接口 const getTrain = (id) => http.get('/course/front/trainCourse/getById', { params: { id } }); const getTree = (courseId) => http.get('/course/front/period/list/tree/studyLog', { params: { courseId, isFront: 1 } }); const getPeriod = (periodId) => http.get('/course/front/period/getById', { params: { periodId: Number(periodId) } }); // 播放前置校验:{ canPlay, periodId, timeRemaining } // canPlay=false 表示「你有未完成课时,请按顺序播放」 const getLimit = (periodId) => http.get('/course/front/period/studyLimit', { params: { periodId: Number(periodId) } }); // 播放器每 2 秒发一次 isComplete=0,播到结尾发一次 isComplete=1(服务端据此写 completeTime) const postLog = (periodId, isComplete, lastProgressTime) => http.post('/course/front/period/study/log', { periodId: Number(periodId), isComplete: isComplete ? 1 : 0, lastProgressTime: String(Math.round(lastProgressTime || 0)), }); // ---------------------------------------------------------------- 工具 const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const CN = { 1: '视频', 2: '回放', 5: '考试', 6: '作业', 12: '评价', 17: '培训评价' }; function flatten(nodes, out = []) { for (const n of nodes || []) { if (n.periodType || n.type === '3') out.push(n); flatten(n.children || n.periodList || [], out); } return out; } const isDone = (p) => Boolean(p.studyLog && p.studyLog.completeTime); const mark = (p) => (isDone(p) ? '✔ 完成' : p.studyLog ? '◐ 学习中' : '○ 未学'); // ---------------------------------------------------------------- 命令 async function status(trainCourseId) { const t = await getTrain(trainCourseId); const st = t.study || {}; console.log( `培训:${t.courseName || t.name || '?'}\n` + `进度:${st.periodStudy ?? '?'}/${st.periodTotal ?? '?'} 分数:${st.courseStudyScore ?? '-'}`, ); const tree = await getTree(t.id || trainCourseId); for (const p of flatten(tree)) { const type = CN[String(p.periodType)] || `type${p.periodType}`; console.log( `${mark(p)} id=${p.id} ${type} ${p.name} fileType=${(p.file && p.file.fileType) || '-'}` + (p.studyLog && p.studyLog.lastProgressTime ? ` last=${p.studyLog.lastProgressTime}` : ''), ); } return t; } async function watch(trainCourseId, opts) { const t = await getTrain(trainCourseId); const tree = await getTree(t.id || trainCourseId); const all = flatten(tree); const pending = all.filter((p) => !isDone(p) && ['1', '2', '3'].includes(String(p.periodType))); if (!pending.length) { console.log('没有待学的视频课时'); return; } for (const p of pending) { const label = `[${p.id}] ${p.name}`; try { const limit = await getLimit(p.id); if (limit && limit.canPlay === false) { console.log(`${label} 服务端要求按顺序播放,先学课时 ${limit.periodId},跳过`); continue; } if (limit && limit.timeRemaining) console.log(`${label} 今日剩余播放时长 ${limit.timeRemaining}`); } catch (e) { console.log(`${label} 前置校验不可用(继续尝试):${e.message}`); } const fileType = (p.file && p.file.fileType) || ''; if (fileType === 'mooc_video' || fileType === 'cloud_video') { console.log(`${label} fileType=${fileType}:这种课时的进度由播放器上报,纯接口大概率写不进 completeTime`); } let dur = Number((p.file && p.file.duration) || p.duration || 0); if (!dur) { try { const d = await getPeriod(p.id); dur = Number((d.file && d.file.duration) || d.duration || 0); } catch { /* ignore */ } } dur = Math.min(dur || 600, Number(opts.limit) || 900); console.log(`${label} 开始上报,总时长 ${dur}s,心跳 ${opts.beat}s`); let failed = false; for (let cur = 0; cur <= dur; cur += opts.beat) { await sleep(opts.beat * 1000); try { await postLog(p.id, 0, cur); } catch (e) { console.log(`${label} 心跳失败:${e.message}`); failed = true; break; } } if (failed) continue; try { await postLog(p.id, 1, dur); } catch (e) { console.log(`${label} 完成上报失败:${e.message}`); continue; } await sleep(1000); try { const d = await getPeriod(p.id); const sl = d.studyLog || {}; if (sl.completeTime) console.log(`${label} ✔ 服务端已写 completeTime=${sl.completeTime}`); else console.log(`${label} ✘ 接口 200 但 completeTime 仍为空(进度 ${sl.progress ?? '?'})`); } catch (e) { console.log(`${label} 回读失败:${e.message}`); } } const after = await getTrain(trainCourseId); const st = after.study || {}; console.log(`最终进度:${st.periodStudy ?? '?'}/${st.periodTotal ?? '?'}`); } // ---------------------------------------------------------------- CLI const [cmd, id, ...rest] = process.argv.slice(2); const argv = {}; for (let i = 0; i < rest.length; i += 2) { if (rest[i].startsWith('--')) argv[rest[i].slice(2)] = rest[i + 1]; } const opts = { beat: Number(argv.beat) || 2, limit: Number(argv.limit) || 900 }; try { if (cmd === 'status') await status(id); else if (cmd === 'watch') await watch(id, opts); else { console.log('用法:node js/study-axios.mjs [--beat 2] [--limit 900]'); process.exit(2); } } catch (e) { console.error('失败:', e.message); process.exit(1); }