mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
80eef46b4e
* 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
267 lines
9.7 KiB
TypeScript
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;
|
|
}
|