feat: parse and merge undergraduate exam schedules
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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="&semester.id=503&examType.id=1&_=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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user