/ clis / chaoxing / exams.js
exams.js
 1  import { cli, Strategy } from '@jackwener/opencli/registry';
 2  import { getCourses, initSession, enterCourse, getTabIframeUrl, parseExamsFromDom, sleep, } from './utils.js';
 3  cli({
 4      site: 'chaoxing',
 5      name: 'exams',
 6      description: '学习通考试列表',
 7      domain: 'mooc2-ans.chaoxing.com',
 8      strategy: Strategy.COOKIE,
 9      timeoutSeconds: 90,
10      args: [
11          { name: 'course', type: 'string', help: '按课程名过滤(模糊匹配)' },
12          {
13              name: 'status',
14              type: 'string',
15              default: 'all',
16              choices: ['all', 'upcoming', 'ongoing', 'finished'],
17              help: '按状态过滤',
18          },
19          { name: 'limit', type: 'int', default: 20, help: '最大返回数量' },
20      ],
21      columns: ['rank', 'course', 'title', 'start', 'end', 'status', 'score'],
22      func: async (page, kwargs) => {
23          const { course: courseFilter, status: statusFilter = 'all', limit = 20 } = kwargs;
24          // 1. Establish session
25          await initSession(page);
26          // 2. Get courses
27          const courses = await getCourses(page);
28          if (!courses.length)
29              throw new Error('未获取到课程列表,请确认已登录学习通');
30          const filtered = courseFilter
31              ? courses.filter(c => c.title.includes(courseFilter))
32              : courses;
33          if (courseFilter && !filtered.length) {
34              throw new Error(`未找到匹配「${courseFilter}」的课程`);
35          }
36          // 3. Per-course: enter → click 考试 tab → navigate to iframe → parse
37          const allRows = [];
38          for (const c of filtered) {
39              try {
40                  await enterCourse(page, c);
41                  const iframeUrl = await getTabIframeUrl(page, '考试');
42                  if (!iframeUrl)
43                      continue;
44                  await page.goto(iframeUrl);
45                  await page.wait(2);
46                  const rows = await parseExamsFromDom(page, c.title);
47                  allRows.push(...rows);
48              }
49              catch {
50                  // Single course failure: skip, continue
51              }
52              if (filtered.length > 1)
53                  await sleep(600);
54          }
55          // 4. Sort: upcoming first
56          allRows.sort((a, b) => {
57              const order = (s) => s === '未开始' ? 0 : s === '进行中' ? 1 : s === '已结束' ? 2 : s === '已完成' ? 3 : 4;
58              return order(a.status) - order(b.status);
59          });
60          // 5. Filter by status
61          const statusMap = {
62              upcoming: ['未开始'],
63              ongoing: ['进行中'],
64              finished: ['已结束', '已完成'],
65          };
66          const finalRows = statusFilter === 'all'
67              ? allRows
68              : allRows.filter(r => statusMap[statusFilter]?.includes(r.status));
69          return finalRows.slice(0, Number(limit)).map((item, i) => ({
70              rank: i + 1,
71              ...item,
72          }));
73      },
74  });