diff --git a/mobile/lib/data/exam_file_import.dart b/mobile/lib/data/exam_file_import.dart new file mode 100644 index 0000000..f6922a3 --- /dev/null +++ b/mobile/lib/data/exam_file_import.dart @@ -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('未识别到学校排考页面'); + } +} diff --git a/mobile/lib/features/school/undergraduate/exam_adapter.dart b/mobile/lib/features/school/undergraduate/exam_adapter.dart new file mode 100644 index 0000000..383727d --- /dev/null +++ b/mobile/lib/features/school/undergraduate/exam_adapter.dart @@ -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() + .toSet(); + final typeValues = scope + .querySelectorAll('input[name="examType.id"]') + .map((e) => e.attributes['value']) + .whereType() + .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 = {}; + 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 parts, + ) { + final expected = query.types.map((t) => t.id).toSet(), + seen = {}, + records = {}; + 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(), + ); + } +} diff --git a/mobile/test/fixtures/school/undergraduate-exams.html b/mobile/test/fixtures/school/undergraduate-exams.html new file mode 100644 index 0000000..e6c11d5 --- /dev/null +++ b/mobile/test/fixtures/school/undergraduate-exams.html @@ -0,0 +1,82 @@ +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
课程序号课程名称考试日期考试安排考试地点座位号考试情况考试类型其它说明
E1考试课程12026-06-22 第17周 星期一(2026-06-22) 14:00-16:00 +示例考场1正常期末考试 +
E2考试课程22026-06-23 第17周 星期二(2026-06-23) 09:30-11:30 +示例考场1正常期末考试 +
E3考试课程32026-06-30 第18周 星期二(2026-06-30) 14:00-16:00 +示例考场1正常期末考试 +
E4考试课程42026-07-01 第18周 星期三(2026-07-01) 09:30-11:30 +示例考场1正常期末考试 +
\ No newline at end of file diff --git a/mobile/test/school/undergraduate_exam_adapter_test.dart b/mobile/test/school/undergraduate_exam_adapter_test.dart new file mode 100644 index 0000000..7fd55ff --- /dev/null +++ b/mobile/test/school/undergraduate_exam_adapter_test.dart @@ -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 = + '
' + + [ + '课程序号', + '课程名称', + '考试日期', + '考试安排', + '考试地点', + '座位号', + '考试情况', + '考试类型', + '其它说明', + ].map((s) => '').join() + + '
$s
暂无考试安排
'; + expect(UndergraduateExamAdapter.extract(empty).records, isEmpty); + expect( + () => UndergraduateExamAdapter.extract(empty.replaceAll('暂无考试安排', '')), + throwsFormatException, + ); + expect( + () => UndergraduateExamAdapter.extract('登录'), + 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'); + }); +}