Files
jackwener__opencli/clis/chaoxing/utils.ts
jakevin 80eef46b4e refactor: monorepo adapter separation (clis/ at root) (#782)
* refactor: move adapters from src/clis/ to root clis/ for monorepo separation

Separates CLI adapters from the core runtime to prepare for independent
adapter distribution via postinstall fetch.

Key changes:
- Move src/clis/ → clis/ (adapters at repo root)
- Change tsconfig rootDir from "src" to "." so tsc compiles both
- Create root-level shim files (registry.ts, errors.ts, etc.) so adapter
  relative imports (../../registry.js) resolve correctly
- Update build-manifest.ts, main.ts paths for new dist/src/ structure
- Expand ensureUserCliCompatShims() to cover all adapter import targets
  (types, utils, logger, launcher, browser/*, download/*, pipeline/*)
- Add scripts/fetch-adapters.js postinstall for ~/.opencli/clis/ sync
- Update vitest.config.ts adapter test paths
- Add package.json files field to exclude adapters from npm package

Official adapter files are unconditionally overwritten on update;
user-created files not in the manifest are preserved.

* fix: add dist/clis/ and cli-manifest.json to npm files, harden fetch-adapters

- Add dist/clis/ and dist/cli-manifest.json to package.json files field
  so built-in adapters and manifest ship with the npm package
- Replace execSync with execFileSync to prevent command injection
- Add version check to skip redundant adapter fetches
- Track tmpRoot explicitly for reliable cleanup

* fix: address review blockers — manifest-based updates, global-only fetch, first-run fallback

1. Manifest-based update strategy:
   - Read old manifest to identify previously-official files
   - Clean up files removed upstream (in old manifest but not new)
   - User-created files (never in any manifest) remain untouched

2. Only run fetch-adapters on global install (npm_config_global=true)
   or explicit OPENCLI_FETCH=1, preventing heavy side effects for
   local/dev installs

3. First-run fallback in discovery.ts:
   - ensureUserAdapters() checks for adapter-manifest.json
   - If missing and ~/.opencli/clis/ is empty, spawns fetch-adapters.js
   - Guarantees adapters are available even with --ignore-scripts

* fix: remove OPENCLI_FETCH env var, use internal _OPENCLI_FIRST_RUN instead

* feat: also support OPENCLI_FETCH=1 for explicit adapter fetch trigger

* simplify: replace git clone with local copy from dist/clis/

Adapters already ship in the npm package (dist/clis/), so there's no
need to clone from GitHub. Copy directly from the installed package:

- Eliminates git, curl, tar dependencies
- No network calls in postinstall
- No timeout/offline issues
- Version always matches the installed CLI
- ~65 lines of clone/download code replaced by one cpSync loop
2026-04-05 01:46:36 +08:00

267 lines
9.7 KiB
TypeScript

/**
* Chaoxing (学习通) shared helpers.
*
* Flow: initSession → getCourses → enterCourse → getTabIframeUrl → navigate → parse DOM
* Chaoxing has no flat "list all assignments" API; data is behind session-gated
* course pages loaded as iframes.
*/
import type { IPage } from '../../types.js';
// ── Utilities ────────────────────────────────────────────────────────
/** Sleep for given milliseconds (anti-scraping delay). */
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** Execute a credentialed fetch in the browser context, returning JSON or text. */
export async function fetchChaoxing(page: IPage, url: string): Promise<any> {
const urlJs = JSON.stringify(url);
return page.evaluate(`
async () => {
const res = await fetch(${urlJs}, { credentials: "include" });
const text = await res.text();
try { return JSON.parse(text); } catch {}
return text;
}
`);
}
/** Format a timestamp (seconds or milliseconds or date string) to YYYY-MM-DD HH:mm. */
export function formatTimestamp(ts: unknown): string {
if (ts == null || ts === '' || ts === 0) return '';
if (typeof ts === 'string' && !/^\d+$/.test(ts.trim())) return ts.trim();
const num = Number(ts);
if (Number.isNaN(num) || num <= 0) return String(ts);
const millis = num > 1e12 ? num : num * 1000;
const d = new Date(millis);
if (Number.isNaN(d.getTime())) return String(ts);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
/** Map numeric work status to Chinese label. */
export function workStatusLabel(status: unknown): string {
if (status == null || status === '') return '未知';
const s = Number(status);
if (s === 0) return '未交';
if (s === 1) return '已交';
if (s === 2) return '已批阅';
const str = String(status).trim();
return str || '未知';
}
// ── Course list ──────────────────────────────────────────────────────
export interface ChaoxingCourse {
courseId: string;
classId: string;
cpi: string;
title: string;
}
/** Fetch enrolled course list via backclazzdata JSON API. */
export async function getCourses(page: IPage): Promise<ChaoxingCourse[]> {
const resp = await fetchChaoxing(
page,
'https://mooc1-api.chaoxing.com/mycourse/backclazzdata?view=json&rss=1',
);
if (!resp || typeof resp !== 'object') return [];
const channelList: any[] = resp.channelList ?? [];
const courses: ChaoxingCourse[] = [];
for (const channel of channelList) {
const content = channel?.content;
if (!content) continue;
const courseData = content.course?.data;
if (!Array.isArray(courseData)) continue;
for (const c of courseData) {
courses.push({
courseId: String(c.id ?? ''),
classId: String(content.id ?? ''),
cpi: String(channel.cpi ?? ''),
title: String(c.name ?? ''),
});
}
}
return courses;
}
// ── Session & course entry ───────────────────────────────────────────
/** Navigate to the interaction page to establish a Chaoxing session. */
export async function initSession(page: IPage): Promise<void> {
await page.goto('https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction');
}
/**
* Enter a course via stucoursemiddle redirect (establishes course session + enc).
* After this call the browser is on the course page.
*/
export async function enterCourse(page: IPage, course: ChaoxingCourse): Promise<void> {
const url =
`https://mooc1.chaoxing.com/visit/stucoursemiddle` +
`?courseid=${course.courseId}&clazzid=${course.classId}&cpi=${course.cpi}&ismooc2=1&v=2`;
await page.goto(url);
}
/**
* On the course page, click a tab (作业 / 考试) and return the iframe src
* that gets loaded. Returns empty string if the tab is not found.
*/
export async function getTabIframeUrl(page: IPage, tabName: string): Promise<string> {
const nameJs = JSON.stringify(tabName);
const result: any = await page.evaluate(`
async () => {
const tabs = document.querySelectorAll('a[data-url]');
let target = null;
for (const tab of tabs) {
if ((tab.innerText || '').trim() === ${nameJs}) { target = tab; break; }
}
if (!target) return '';
target.click();
await new Promise(r => setTimeout(r, 2000));
const iframe = document.getElementById('frame_content-hd') || document.querySelector('iframe');
return iframe?.src || '';
}
`);
return typeof result === 'string' ? result : '';
}
// ── Assignment parsing ───────────────────────────────────────────────
export interface AssignmentRow {
course: string;
title: string;
deadline: string;
status: string;
score: string;
}
/**
* Parse assignments from the current page DOM (the 作业列表 page).
* The page uses `.ulDiv li` items with status/deadline/score info.
*/
export async function parseAssignmentsFromDom(page: IPage, courseName: string): Promise<AssignmentRow[]> {
const raw: any[] = await page.evaluate(`
(() => {
const items = [];
// Each assignment is a li or div block; try multiple selectors
const blocks = document.querySelectorAll('.ulDiv li, .work-list-item, .listContent > div, ul > li');
for (const block of blocks) {
const text = (block.innerText || '').trim();
if (!text || text.length < 3) continue;
// Skip filter buttons and headers
if (/^(全部|已完成|未完成|筛选)$/.test(text)) continue;
items.push(text);
}
// Fallback: split body text by common patterns
if (items.length === 0) {
const body = (document.body?.innerText || '').trim();
return [body];
}
return items;
})()
`) ?? [];
const rows: AssignmentRow[] = [];
for (const text of raw) {
if (typeof text !== 'string' || text.length < 3) continue;
// Skip noise
if (/^(全部|已完成|未完成|筛选|暂无|提交的作业将经过)/.test(text)) continue;
const lines = text.split('\n').map((l: string) => l.trim()).filter(Boolean);
if (!lines.length) continue;
// First meaningful line is the title
const title = lines[0].replace(/\s+/g, ' ').trim();
if (!title || /^(全部|已完成|未完成|筛选)$/.test(title)) continue;
// Extract status: 未交 / 待批阅 / 已完成 / 已批阅
const statusMatch = text.match(/(未交|待批阅|已完成|已批阅)/);
const status = statusMatch?.[1] ?? '';
// Extract deadline: "剩余XXX" or date pattern
const remainMatch = text.match(/(剩余[\d天小时分钟秒]+)/);
const dateMatch = text.match(/(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}(?:\s+\d{1,2}:\d{2})?)/);
const deadline = remainMatch?.[1] ?? dateMatch?.[1] ?? '';
// Extract score (exclude "分钟")
const scoreMatch = text.match(/(\d+(?:\.\d+)?)\s*分(?!钟)/);
const score = scoreMatch?.[1] ?? '';
rows.push({ course: courseName, title, deadline, status, score });
}
return rows;
}
// ── Exam parsing ─────────────────────────────────────────────────────
export interface ExamRow {
course: string;
title: string;
start: string;
end: string;
status: string;
score: string;
}
/** Parse exams from the current page DOM (the 考试列表 page). */
export async function parseExamsFromDom(page: IPage, courseName: string): Promise<ExamRow[]> {
const raw: any[] = await page.evaluate(`
(() => {
const items = [];
const blocks = document.querySelectorAll('.ulDiv li, .exam-list-item, .listContent > div, ul > li');
for (const block of blocks) {
const text = (block.innerText || '').trim();
if (!text || text.length < 3) continue;
if (/^(全部|已完成|未完成|筛选|暂无)$/.test(text)) continue;
items.push(text);
}
if (items.length === 0) {
const body = (document.body?.innerText || '').trim();
return [body];
}
return items;
})()
`) ?? [];
// Check for "暂无考试"
if (raw.length === 1 && typeof raw[0] === 'string' && raw[0].includes('暂无考试')) {
return [];
}
const rows: ExamRow[] = [];
for (const text of raw) {
if (typeof text !== 'string' || text.length < 3) continue;
if (/^(全部|已完成|未完成|筛选|暂无)/.test(text)) continue;
const lines = text.split('\n').map((l: string) => l.trim()).filter(Boolean);
if (!lines.length) continue;
const title = lines[0].replace(/\s+/g, ' ').trim();
if (!title || /^(全部|已完成|未完成|筛选)$/.test(title)) continue;
// Extract dates
const dates = text.match(/\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{2}/g) ?? [];
const start = dates[0] ?? '';
const end = dates[1] ?? '';
// Status
const statusMatch = text.match(/(未开始|进行中|已结束|已完成|未交|待批阅)/);
let status = statusMatch?.[1] ?? '';
if (!status && text.includes('剩余')) status = '进行中';
// Score (exclude "分钟")
const scoreMatch = text.match(/(\d+(?:\.\d+)?)\s*分(?!钟)/);
const score = scoreMatch?.[1] ?? '';
rows.push({ course: courseName, title, start, end, status, score });
}
return rows;
}