feat: 添加账号支持选择学习平台

账号新增 platform 字段,登录/刷新课程/发现课程按平台分发;同时把 JS 脚本收进 script/(目录规范调整)。
This commit is contained in:
study-tool
2026-10-30 01:13:47 +08:00
parent e8634a6317
commit 1a3347f7f9
29 changed files with 449 additions and 297 deletions
+215
View File
@@ -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);
}