/** * API出勤记录与本地课评交叉核对脚本 * 严格遵循《课评写入和查找规则V1.0》的两级查找逻辑 * * 用法: node check-api-vs-local.js <开始日期> <结束日期> * 示例: node check-api-vs-local.js 2026-05-15 2026-05-17 */ const axios = require('axios'); const fs = require('fs'); const path = require('path'); require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); const API_BASE_URL = process.env.API_BASE_URL; const AUTHORIZATION = process.env.AUTHORIZATION; const MEMORY_DIR = path.join(__dirname, '..', '.claude', 'memory', 'class'); // ============ 课程编号别名映射 ============ // API课程编号 → 本地目录名可能的大小写变体 const DIR_CODE_ALIASES = { 'KITTEN04': ['Kitten04', 'KITTEN04', 'kitten04'], 'KITTEN': ['Kitten', 'KITTEN', 'kitten'], 'AICODE03': ['AICODE03'], 'AICODE01': ['AICODE01'], 'CSP03': ['CSP03'], 'CSP05': ['CSP05'], }; // API课程编号 → 文件名中可能的别名 const FILE_CODE_ALIASES = { 'KITTEN04': ['KITTEN04', 'Kitten04', 'kitten04', 'K4', 'k4'], 'KITTEN': ['KITTEN', 'Kitten', 'kitten', 'K4', 'k4'], 'AICODE03': ['AICODE03'], 'AICODE01': ['AICODE01'], 'CSP03': ['CSP03'], 'CSP05': ['CSP05'], }; // 非常规班目录关键词 const IRREGULAR_KEYWORDS = ['集训', '体验', '临时', '测试']; // ============ 辅助函数 ============ function extractCourseCode(className) { const match = className.match(/^([A-Z]+\d+)/); return match ? match[1] : className; } function getWeekday(dateStr) { const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; const d = new Date(dateStr + 'T00:00:00'); return weekdays[d.getDay()]; } function extractTimeCode(timePeriod) { if (!timePeriod) return ''; const match = timePeriod.match(/(\d{2}):(\d{2})/); return match ? match[1] + match[2] : ''; } function getFileAliases(courseCode) { return FILE_CODE_ALIASES[courseCode] || [courseCode]; } function getDirAliases(courseCode) { return DIR_CODE_ALIASES[courseCode] || [courseCode]; } // 列出所有本地班级目录 function listAllClassDirs() { if (!fs.existsSync(MEMORY_DIR)) return []; return fs.readdirSync(MEMORY_DIR, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name); } // 判断是否非常规班目录 function isIrregularDir(dirName) { return IRREGULAR_KEYWORDS.some(k => dirName.includes(k)); } // ============ 两级查找逻辑 ============ /** * 第1级:精确匹配(课程编号 + 老师 + 星期 + 时间) */ function findLevel1Dir(courseCode, weekday, timeCode) { const dirs = listAllClassDirs(); const aliases = getDirAliases(courseCode); for (const code of aliases) { const pattern = new RegExp(`${code}.*橙子.*${weekday}.*${timeCode}`, 'i'); const match = dirs.find(d => pattern.test(d)); if (match) return match; } // 模糊回退 for (const code of aliases) { const match = dirs.find(d => d.toLowerCase().includes(code.toLowerCase()) && d.includes(weekday) && (timeCode === '' || d.includes(timeCode)) ); if (match) return match; } return null; } /** * 第2级:放宽时间(课程编号 + 老师),排除非常规班和第1级已匹配的目录 */ function findLevel2Dirs(courseCode, excludeDir) { const dirs = listAllClassDirs(); const aliases = getDirAliases(courseCode); return dirs.filter(dir => { if (dir === excludeDir) return false; if (isIrregularDir(dir)) return false; if (!dir.includes('橙子')) return false; return aliases.some(code => dir.toLowerCase().includes(code.toLowerCase())); }); } /** * 在指定目录下检查学生课评文件 * 返回: { hasFeedback, files, fileTypes, note } */ function checkStudentFeedback(baseDir, studentName, dateNum, aliases) { const feedbackDir = path.join(baseDir, studentName, 'feedback'); if (!fs.existsSync(feedbackDir)) { return { hasFeedback: false, files: [], fileTypes: [], note: '无feedback目录' }; } const files = fs.readdirSync(feedbackDir) .filter(f => f.endsWith('.md')) .filter(f => { const dateMatch = f.startsWith(dateNum); const codeMatch = aliases.some(a => f.includes(a)); return dateMatch && codeMatch; }); const fileTypes = files.map(f => { if (f.includes('(请假)')) return '请假'; if (f.includes('(补课')) return '补课'; if (f.includes('(未到')) return '未到'; return '正常课评'; }); return { hasFeedback: files.length > 0, files, fileTypes, note: files.length > 0 ? fileTypes.join(', ') : '无匹配文件' }; } /** * 检查学生目录是否存在(用于判断是"新增"还是"有目录但无文件") */ function hasStudentDir(baseDir, studentName) { return fs.existsSync(path.join(baseDir, studentName)); } // ============ API查询 ============ async function queryDailySchedule(date) { try { const response = await axios.get(`${API_BASE_URL}/reports/teaching-schedule`, { headers: { 'Authorization': AUTHORIZATION }, params: { teacher_name: '橙子(程城)', teaching_date: date }, timeout: 15000 }); if (response.data.code === 0 && response.data.data.items.length > 0) { return response.data.data.items; } return []; } catch (error) { console.error(`查询 ${date} 失败:`, error.response?.data?.message || error.message); return []; } } // ============ 主函数 ============ async function main() { const args = process.argv.slice(2); if (args.length < 2) { console.log('用法: node check-api-vs-local.js <开始日期> <结束日期>'); console.log('示例: node check-api-vs-local.js 2026-05-15 2026-05-17'); process.exit(1); } const beginDate = args[0]; const endDate = args[1]; console.log('========================================'); console.log(' API出勤记录 vs 本地课评 交叉核对'); console.log(` 日期范围: ${beginDate} 至 ${endDate}`); console.log(' 查找规则: V1.0 两级查找(精确 → 放宽时间)'); console.log('========================================\n'); // 生成日期列表(本地时区) const dates = []; let curr = new Date(beginDate + 'T00:00:00'); const end = new Date(endDate + 'T00:00:00'); while (curr <= end) { const y = curr.getFullYear(); const m = String(curr.getMonth() + 1).padStart(2, '0'); const d = String(curr.getDate()).padStart(2, '0'); dates.push(`${y}-${m}-${d}`); curr.setDate(curr.getDate() + 1); } const allResults = []; for (const date of dates) { console.log(`\n[${date} ${getWeekday(date)}]`); const courses = await queryDailySchedule(date); if (courses.length === 0) { console.log(' 无课程安排'); continue; } for (const course of courses) { const courseCode = extractCourseCode(course.class_name); const weekday = getWeekday(date); const timeCode = extractTimeCode(course.teaching_time_period); const dateNum = date.replace(/-/g, ''); const fileAliases = getFileAliases(courseCode); console.log(`\n 课程: ${course.class_name}`); console.log(` 时间: ${course.teaching_time_period}`); console.log(` API应到: ${course.student_count}人 (${course.student_names?.join(', ') || '无名单'})`); // ====== 两级查找:确定搜索目录范围 ====== const level1Dir = findLevel1Dir(courseCode, weekday, timeCode); const level2Dirs = findLevel2Dirs(courseCode, level1Dir); const searchDirs = []; if (level1Dir) searchDirs.push({ dir: level1Dir, level: 1 }); for (const d of level2Dirs) searchDirs.push({ dir: d, level: 2 }); if (searchDirs.length === 0) { console.log(` 本地目录: ❌ 未找到任何匹配目录 (第1/2级均失败)`); allResults.push({ date, courseCode, time: course.teaching_time_period, className: course.class_name, level1Dir: null, level2Dirs: [], searchDirs: [], apiStudents: course.student_names || [], apiCount: course.student_count, studentResults: [], feedbackCount: 0, issues: ['本地无匹配班级目录'] }); continue; } console.log(` 第1级目录: ${level1Dir || '无'}`); if (level2Dirs.length > 0) console.log(` 第2级目录: ${level2Dirs.join(', ')}`); // ====== 以学生为中心查找课评 ====== const apiNames = (course.student_names || []).map(n => n.replace(/\(.*?\)/g, '').trim()); const studentResults = []; for (const apiName of apiNames) { let found = false; let foundAt = null; for (const { dir, level } of searchDirs) { // 1a/2a: 主目录 S/feedback/ const mainResult = checkStudentFeedback( path.join(MEMORY_DIR, dir), apiName, dateNum, fileAliases ); if (mainResult.hasFeedback) { found = true; foundAt = { dir, level, type: '主目录', ...mainResult }; break; } // 1b/2b: 补课目录 补课/S/feedback/ const makeupResult = checkStudentFeedback( path.join(MEMORY_DIR, dir, '补课'), apiName, dateNum, fileAliases ); if (makeupResult.hasFeedback) { found = true; foundAt = { dir, level, type: '补课目录', ...makeupResult }; break; } } studentResults.push({ name: apiName, found, foundAt }); } // ====== 比对:收集问题 ====== const issues = []; const foundStudents = studentResults.filter(s => s.found); const missingStudents = studentResults.filter(s => !s.found); const foundInLevel2 = foundStudents.filter(s => s.foundAt.level === 2); // 1. 未找到课评的学生 for (const s of missingStudents) { // 检查在哪些目录中有学生目录(但无课评文件) const dirsWithStudent = searchDirs.filter(({ dir }) => hasStudentDir(path.join(MEMORY_DIR, dir), s.name) ); if (dirsWithStudent.length > 0) { const dirNames = dirsWithStudent.map(d => d.dir).join(', '); issues.push(`${s.name}: 有学生目录但无课评文件 (在: ${dirNames})`); } else { issues.push(`${s.name}: 本地无学生目录(可能新增/转班)`); } } // 2. 在第2级目录中找到的学生(提示,非错误) for (const s of foundInLevel2) { issues.push(`${s.name}: 课评在「${s.foundAt.dir}」找到(${s.foundAt.type}),建议确认是否为补课/调时间`); } // 3. 检查课评文件日期(对已找到的学生) for (const s of foundStudents) { for (const f of s.foundAt.files) { const fileDate = f.substring(0, 8); if (fileDate !== dateNum) { issues.push(`${s.name}: 文件日期不匹配 ${f} (文件${fileDate} vs API${dateNum})`); } } } // 4. 检查课程编号(对已找到的学生) for (const s of foundStudents) { for (const f of s.foundAt.files) { const codeMatch = fileAliases.some(a => f.includes(a)); if (!codeMatch) { issues.push(`${s.name}: 课程编号不匹配 ${f} (期望含${courseCode}或其别名)`); } } } // ====== 输出结果 ====== const feedbackCount = foundStudents.length; if (issues.length === 0) { console.log(` ✅ 核对通过 (${feedbackCount}/${apiNames.length}人有课评)`); } else { const realIssues = issues.filter(i => !i.includes('建议确认')); // 第2级找到是提示不是错误 const hints = issues.filter(i => i.includes('建议确认')); if (realIssues.length > 0) { console.log(` ⚠️ 发现 ${realIssues.length} 个问题:`); realIssues.forEach(issue => console.log(` - ${issue}`)); } if (hints.length > 0) { console.log(` 💡 ${hints.length} 个提示:`); hints.forEach(h => console.log(` - ${h}`)); } } allResults.push({ date, courseCode, time: course.teaching_time_period, className: course.class_name, level1Dir, level2Dirs, searchDirs, apiStudents: course.student_names || [], apiCount: course.student_count, studentResults, feedbackCount, issues }); } } // ============ 汇总报告 ============ console.log('\n\n========================================'); console.log(' 核对汇总报告'); console.log('========================================'); const totalCourses = allResults.length; const okCourses = allResults.filter(r => r.issues.filter(i => !i.includes('建议确认')).length === 0 ).length; const issueCourses = allResults.filter(r => r.issues.filter(i => !i.includes('建议确认')).length > 0 ); const missingDirCourses = allResults.filter(r => r.searchDirs && r.searchDirs.length === 0); const level2Hints = allResults.filter(r => r.issues.some(i => i.includes('建议确认')) ).length; console.log(`\n总课程数: ${totalCourses}`); console.log(`核对通过: ${okCourses}`); console.log(`存在问题: ${issueCourses.length}`); console.log(`无本地目录: ${missingDirCourses.length}`); console.log(`有第2级提示: ${level2Hints}`); if (issueCourses.length > 0) { console.log('\n--- 问题详情 ---'); issueCourses.forEach(r => { const realIssues = r.issues.filter(i => !i.includes('建议确认')); console.log(`\n[${r.date} ${r.time}] ${r.className}`); console.log(` 第1级: ${r.level1Dir || '无'}, 第2级: ${r.level2Dirs.join(', ') || '无'}`); realIssues.forEach(issue => console.log(` ⚠️ ${issue}`)); }); } // ============ 保存报告 ============ const reportPath = path.join(__dirname, '..', 'output', `api核对报告_${beginDate}_${endDate}.md`); const reportDir = path.dirname(reportPath); if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true }); let md = `# API出勤记录 vs 本地课评核对报告\n\n`; md += `- **日期范围**: ${beginDate} 至 ${endDate}\n`; md += `- **核对时间**: ${new Date().toLocaleString()}\n`; md += `- **老师**: 橙子(程城)\n`; md += `- **查找规则**: V1.0 两级查找(精确→放宽时间)\n\n`; md += `## 汇总\n\n`; md += `- 总课程数: ${totalCourses}\n`; md += `- 核对通过: ${okCourses}\n`; md += `- 存在问题: ${issueCourses.length}\n`; md += `- 无本地目录: ${missingDirCourses.length}\n`; md += `- 有第2级查找提示: ${level2Hints}\n\n`; md += `## 详细核对结果\n\n`; md += `| 日期 | 时间 | 课程 | 第1级目录 | 第2级目录 | API人数 | 找到课评 | 状态 |\n`; md += `|------|------|------|-----------|-----------|---------|----------|------|\n`; for (const r of allResults) { const hasRealIssues = r.issues.filter(i => !i.includes('建议确认')).length > 0; const hasHints = r.issues.some(i => i.includes('建议确认')); let status = '✅'; if (r.searchDirs.length === 0) status = '❌'; else if (hasRealIssues) status = '⚠️'; else if (hasHints) status = '💡'; md += `| ${r.date} | ${r.time} | ${r.className} | ${r.level1Dir || '-'} | ${r.level2Dirs.join(', ') || '-'} | ${r.apiCount} | ${r.feedbackCount || 0} | ${status} |\n`; } if (issueCourses.length > 0) { md += `\n## 问题详情\n\n`; for (const r of issueCourses) { const realIssues = r.issues.filter(i => !i.includes('建议确认')); if (realIssues.length === 0) continue; md += `### ${r.date} ${r.time} - ${r.className}\n\n`; md += `- 第1级目录: ${r.level1Dir || '无'}\n`; md += `- 第2级目录: ${r.level2Dirs.join(', ') || '无'}\n`; md += `- API学生: ${r.apiStudents.join(', ')}\n\n`; const foundList = r.studentResults.filter(s => s.found).map(s => { const loc = s.foundAt.level === 1 ? '第1级' : '第2级'; return `${s.name}(${loc},${s.foundAt.type})`; }).join(', '); const missingList = r.studentResults.filter(s => !s.found).map(s => s.name).join(', '); if (foundList) md += `- 已找到: ${foundList}\n`; if (missingList) md += `- 未找到: ${missingList}\n`; md += `\n`; for (const issue of realIssues) { md += `- ⚠️ ${issue}\n`; } md += `\n`; } } // 第2级查找提示详情 const hintCourses = allResults.filter(r => r.issues.some(i => i.includes('建议确认'))); if (hintCourses.length > 0) { md += `\n## 第2级查找提示(课评在非精确匹配目录中找到)\n\n`; for (const r of hintCourses) { const hints = r.issues.filter(i => i.includes('建议确认')); md += `### ${r.date} ${r.time} - ${r.className}\n\n`; md += `- 精确目录: ${r.level1Dir || '无'}\n`; md += `- 实际找到目录: ${r.level2Dirs.join(', ')}\n\n`; for (const h of hints) { md += `- 💡 ${h}\n`; } md += `\n`; } } fs.writeFileSync(reportPath, md); console.log(`\n\n详细报告已保存: ${reportPath}`); } main().catch(console.error);