feat: parse and merge undergraduate exam schedules

This commit is contained in:
Codex
2026-10-14 23:35:30 +08:00
parent 17e4903399
commit ebe70e3eb2
4 changed files with 384 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
import 'dart:convert';
import 'package:html/parser.dart' as html;
import '../domain/exam_schedule.dart';
import '../features/school/school_exam_adapter.dart';
import '../features/school/undergraduate/exam_adapter.dart';
class ExamFileImport {
static ExamSchedule parse(String content) {
if (utf8.encode(content).length > 2 * 1024 * 1024)
throw const FormatException('文件不能超过2MB');
final doc = html.parse(content.replaceFirst('\uFEFF', ''));
if (doc.querySelector('#stdExamTableForm') != null)
return UndergraduateExamAdapter.extract(content);
if (doc.querySelector('#sample-table-1') != null)
return SchoolExamAdapter.extract(content);
throw const FormatException('未识别到学校排考页面');
}
}
@@ -0,0 +1,179 @@
import 'dart:convert';
import 'package:html/dom.dart';
import 'package:html/parser.dart' as html;
import '../../../domain/exam_schedule.dart';
import '../../../domain/school_identity.dart';
import '../../../domain/school_term.dart';
import '../../../domain/undergraduate_exam_query.dart';
class UndergraduateExamAdapter {
static const headers = [
'课程序号',
'课程名称',
'考试日期',
'考试安排',
'考试地点',
'座位号',
'考试情况',
'考试类型',
'其它说明',
];
static ExamSchedule extract(
String content, {
SchoolTerm? expectedTerm,
UndergraduateExamType? expectedType,
}) {
try {
if (utf8.encode(content).length > 2 * 1024 * 1024)
throw const FormatException('考试页面超过2MB');
final doc = html.parse(content);
if (doc.querySelector('input[type="password"]') != null)
throw const FormatException('请先完成学校登录');
final form = doc.querySelector('#stdExamTableForm');
final scope = form ?? doc;
final termValues = scope
.querySelectorAll('input[name="semester.id"]')
.map((e) => e.attributes['value'])
.whereType<String>()
.toSet();
final typeValues = scope
.querySelectorAll('input[name="examType.id"]')
.map((e) => e.attributes['value'])
.whereType<String>()
.toSet();
for (final params in scope.querySelectorAll('input[name="params"]')) {
final query = Uri.splitQueryString(
(params.attributes['value'] ?? '').replaceFirst(RegExp(r'^&'), ''),
);
if (query['semester.id'] != null) termValues.add(query['semester.id']!);
if (query['examType.id'] != null) typeValues.add(query['examType.id']!);
}
// Only a result params assignment is evidence, not calendar initialization.
for (final script in doc.querySelectorAll('script')) {
for (final m in RegExp(
r'''\bvar\s+params\s*=\s*["']([^"']+)["']''',
).allMatches(script.text)) {
final query = Uri.splitQueryString(
m[1]!.replaceFirst(RegExp(r'^&'), ''),
);
if (query['semester.id'] != null)
termValues.add(query['semester.id']!);
if (query['examType.id'] != null)
typeValues.add(query['examType.id']!);
}
}
if (termValues.length != 1 || typeValues.length != 1)
throw const FormatException('排考结果学期或类型不明确');
final term = SchoolTerm(
int.parse(termValues.single),
school: SupportedSchool.uestcUndergraduate,
);
final type = UndergraduateExamType.fromId(int.parse(typeValues.single));
if (expectedTerm != null && expectedTerm != term ||
expectedType != null && expectedType != type)
throw const FormatException('排考结果与所选学期或类型不一致');
Element? table;
for (final candidate in doc.querySelectorAll('table')) {
final cells = candidate
.querySelector('tr')
?.children
.map((c) => c.text.trim())
.toList();
if (cells != null &&
cells.length == headers.length &&
List.generate(
headers.length,
(i) => cells[i] == headers[i],
).every((v) => v)) {
if (table != null) throw const FormatException('排考结果重复');
table = candidate;
}
}
if (table == null) throw const FormatException('排考页面尚未加载完整');
final records = <String, ExamRecord>{};
final explicitEmpty = RegExp(r'暂无考试|无考试安排|没有考试|暂无记录|没有符合条件的记录|没有数据|无数据')
.hasMatch(table.text);
for (final row in table.querySelectorAll('tr').skip(1)) {
final cells = row.children
.map((c) => c.text.replaceAll(RegExp(r'\s+'), ' ').trim())
.toList();
if (cells.length != 9) {
if (explicitEmpty && cells.length == 1) continue;
throw const FormatException('考试记录字段不完整');
}
final rowType = _type(cells[7]);
if (rowType != type.id) throw const FormatException('考试记录类型不一致');
final record = ExamRecord(
code: cells[0],
name: cells[1],
date: cells[2],
time: cells[3],
room: cells[4],
seat: cells[5],
status: cells[6],
examTypeId: rowType,
notes: cells[8],
);
if (record.code.isEmpty || record.name.isEmpty)
throw const FormatException('考试课程缺失');
records.putIfAbsent(record.undergraduateKey, () => record);
}
if (records.isEmpty && !explicitEmpty)
throw const FormatException('尚未识别到明确的排考结果');
return ExamSchedule.fromJson(
ExamSchedule(
semesterLabel: term.label,
termId: '${term.id}',
records: records.values.toList(),
school: term.school,
requestedExamTypes: {type.id},
).toJson(),
);
} on FormatException {
rethrow;
} catch (_) {
throw const FormatException('本科考试页面内容无效,已有考试保持不变');
}
}
static int _type(String text) {
if (text.contains('期末')) return 1;
if (text.contains('期中')) return 2;
if (text.contains('补') || text.contains('缓')) return 3;
throw const FormatException('考试记录类型无法识别');
}
static ExamSchedule merge(
UndergraduateExamQuery query,
List<ExamSchedule> parts,
) {
final expected = query.types.map((t) => t.id).toSet(),
seen = <int>{},
records = <String, ExamRecord>{};
for (final raw in parts) {
final part = ExamSchedule.fromJson(raw.toJson());
if (part.school != query.term.school ||
part.termId != '${query.term.id}' ||
part.requestedExamTypes.length != 1 ||
!expected.contains(part.requestedExamTypes.single) ||
!seen.add(part.requestedExamTypes.single))
throw const FormatException('考试查询批次不一致');
for (final record in part.records) {
records.putIfAbsent(record.undergraduateKey, () => record);
}
}
if (seen.length != expected.length || records.length > 500)
throw const FormatException('考试未全部读取完成或记录超限');
return ExamSchedule.fromJson(
ExamSchedule(
semesterLabel: query.term.label,
termId: '${query.term.id}',
records: records.values.toList(),
school: query.term.school,
requestedExamTypes: expected,
).toJson(),
);
}
}
+82
View File
@@ -0,0 +1,82 @@
<script>var semesterDefault=523;</script><form id="stdExamTableForm" name="stdExamTableForm" action="https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action" method="post" target="examTableFrame">
<input type="hidden" name="params" value="&amp;semester.id=503&amp;examType.id=1&amp;_=1791984366081">
<input type="hidden" name="examTakeId" value="">
<input type="hidden" name="semester.id" value="503">
<input type="hidden" name="examType.id" value="1">
<input type="hidden" name="examApplyType" value="">
</form><table width="100%" class="formTable">
<tbody><tr align="center" bgcolor="#C7DBFF" height="23px">
<td width="10%">课程序号</td>
<td width="18%">课程名称</td>
<td width="12%">考试日期</td>
<td width="22%">考试安排</td>
<td width="10%">考试地点</td>
<td width="6%">座位号</td>
<td width="8%">考试情况</td>
<td width="8%">考试类型</td>
<td width="15%">其它说明</td>
</tr>
<tr class="brightStyle" align="center" onmouseover="swapOverTR(this,this.className)" onmouseout="swapOutTR(this)" onclick="onRowChange(event)" height="23px">
<td>E1</td>
<td>考试课程1</td>
<td>2026-06-22</td>
<td> 第17周 星期一(2026-06-22) 14:00-16:00
</td>
<td>示例考场</td>
<td>1</td>
<td>正常</td>
<td>期末考试</td>
<td>
</td>
</tr>
<tr class="grayStyle" align="center" onmouseover="swapOverTR(this,this.className)" onmouseout="swapOutTR(this)" onclick="onRowChange(event)" height="23px">
<td>E2</td>
<td>考试课程2</td>
<td>2026-06-23</td>
<td> 第17周 星期二(2026-06-23) 09:30-11:30
</td>
<td>示例考场</td>
<td>1</td>
<td>正常</td>
<td>期末考试</td>
<td>
</td>
</tr>
<tr class="brightStyle" align="center" onmouseover="swapOverTR(this,this.className)" onmouseout="swapOutTR(this)" onclick="onRowChange(event)" height="23px">
<td>E3</td>
<td>考试课程3</td>
<td>2026-06-30</td>
<td> 第18周 星期二(2026-06-30) 14:00-16:00
</td>
<td>示例考场</td>
<td>1</td>
<td>正常</td>
<td>期末考试</td>
<td>
</td>
</tr>
<tr class="grayStyle" align="center" onmouseover="swapOverTR(this,this.className)" onmouseout="swapOutTR(this)" onclick="onRowChange(event)" height="23px">
<td>E4</td>
<td>考试课程4</td>
<td>2026-07-01</td>
<td> 第18周 星期三(2026-07-01) 09:30-11:30
</td>
<td>示例考场</td>
<td>1</td>
<td>正常</td>
<td>期末考试</td>
<td>
</td>
</tr>
</tbody></table>
@@ -0,0 +1,103 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/features/school/undergraduate/exam_adapter.dart';
import 'package:hepan_everyday/data/exam_file_import.dart';
import 'package:hepan_everyday/domain/school_identity.dart';
import 'package:hepan_everyday/domain/school_term.dart';
import 'package:hepan_everyday/domain/undergraduate_exam_query.dart';
import 'package:hepan_everyday/domain/exam_schedule.dart';
void main() {
String fixture() =>
File('test/fixtures/school/undergraduate-exams.html').readAsStringSync();
final term = SchoolTerm(503, school: SupportedSchool.uestcUndergraduate);
test('actual exam result term wins over the outer default', () {
final schedule = UndergraduateExamAdapter.extract(fixture());
expect(schedule.termId, '503');
expect(schedule.records, hasLength(4));
expect(schedule.requestedExamTypes, {1});
expect(schedule.records.first.date, '2026-06-22');
expect(schedule.records.first.time, contains('14:00-16:00'));
expect(ExamFileImport.parse(fixture()).termId, '503');
expect(ExamSchedule.fromJson(schedule.toJson()).records.first.status, '正常');
expect(
() => UndergraduateExamAdapter.extract(
fixture(),
expectedTerm: SchoolTerm(
523,
school: SupportedSchool.uestcUndergraduate,
),
),
throwsFormatException,
);
});
test(
'multi type merge preserves both exams for the same course and is complete',
() {
final finalExam = UndergraduateExamAdapter.extract(fixture());
final mid = UndergraduateExamAdapter.extract(
fixture()
.replaceAll('examType.id=1', 'examType.id=2')
.replaceAll(
'name="examType.id" value="1"',
'name="examType.id" value="2"',
)
.replaceAll('期末考试', '期中考试'),
);
final query = UndergraduateExamQuery(
term: term,
types: {UndergraduateExamType.finalExam, UndergraduateExamType.midterm},
);
expect(
UndergraduateExamAdapter.merge(query, [finalExam, mid]).records,
hasLength(8),
);
expect(
() => UndergraduateExamAdapter.merge(query, [finalExam]),
throwsFormatException,
);
expect(
() => UndergraduateExamAdapter.extract(
fixture(),
expectedType: UndergraduateExamType.midterm,
),
throwsFormatException,
);
},
);
test('only recognized explicit empty exam tables are empty success', () {
final empty =
'<form id="stdExamTableForm"><input name="semester.id" value="503"><input name="examType.id" value="1"></form><table><tr>' +
[
'课程序号',
'课程名称',
'考试日期',
'考试安排',
'考试地点',
'座位号',
'考试情况',
'考试类型',
'其它说明',
].map((s) => '<td>$s</td>').join() +
'</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
expect(UndergraduateExamAdapter.extract(empty).records, isEmpty);
expect(
() => UndergraduateExamAdapter.extract(empty.replaceAll('暂无考试安排', '')),
throwsFormatException,
);
expect(
() => UndergraduateExamAdapter.extract('<html>登录</html>'),
throwsFormatException,
);
});
final original = Platform.environment['UESTC_UNDERGRADUATE_EXAM_FIXTURE'];
if (original != null)
test('actual supplied exam export belongs to 503', () {
final result = UndergraduateExamAdapter.extract(
File(original).readAsStringSync(),
);
expect(result.records, hasLength(4));
expect(result.termId, '503');
});
}