1a3347f7f9
账号新增 platform 字段,登录/刷新课程/发现课程按平台分发;同时把 JS 脚本收进 script/(目录规范调整)。
216 lines
7.4 KiB
JavaScript
216 lines
7.4 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 莞易学(study.dgjy.net)学习链路的 axios 实现。
|
||
*
|
||
* 只做三件事:查进度 / 课时上报 / 完成后回读复核。
|
||
* 不负责登录(Middle-Token 从浏览器 cookie 里复制),也不假装能绕过服务端校验。
|
||
*
|
||
* 用法:
|
||
* MIDDLE_TOKEN='<document.cookie 里 Middle-Token 的值>' \
|
||
* node script/reference/study-axios.mjs status 123456
|
||
*
|
||
* MIDDLE_TOKEN=... node script/reference/study-axios.mjs watch 123456
|
||
* MIDDLE_TOKEN=... node script/reference/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 script/reference/study-axios.mjs <status|watch> <trainCourseId> [--beat 2] [--limit 900]');
|
||
process.exit(2);
|
||
}
|
||
} catch (e) {
|
||
console.error('失败:', e.message);
|
||
process.exit(1);
|
||
}
|