feat: 添加账号支持选择学习平台
账号新增 platform 字段,登录/刷新课程/发现课程按平台分发;同时把 JS 脚本收进 script/(目录规范调整)。
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user