Files
study-tool/js/study-axios.mjs
T
study-tool 585a7a28dd feat: study modes (fast/parallel) and course-container completion fix
- settings: study.mode fast|normal and study.parallel 1-5

- normal mode: one headless browser per video, visibility keep-alive, auto-degrade when the platform enforces order

- report study/log/detail/save before isComplete; drop lastProgressTime on completion

- complete periodType 10/13 container nodes and recompute counters via PUT calFinalScore

- capture mode: POST /api/capture records all XHR/fetch including new tabs

- UI: study mode in settings, capture / stop-capture menu items
2026-10-27 22:30:57 +08:00

216 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 莞易学(study.dgjy.net)学习链路的 axios 实现。
*
* 只做三件事:查进度 / 课时上报 / 完成后回读复核。
* 不负责登录(Middle-Token 从浏览器 cookie 里复制),也不假装能绕过服务端校验。
*
* 用法:
* MIDDLE_TOKEN='<document.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 <status|watch> <trainCourseId> [--beat 2] [--limit 900]');
process.exit(2);
}
} catch (e) {
console.error('失败:', e.message);
process.exit(1);
}