Files
TPSchedule/mobile/lib/features/school/undergraduate/exam_adapter.dart
T

205 lines
7.4 KiB
Dart

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)) {
if (explicitEmpty && row.children.length == 1) continue;
final cells = <String>[];
for (final cell in row.children) {
final value = cell.text.replaceAll(RegExp(r'\s+'), ' ').trim();
final span = int.tryParse(cell.attributes['colspan'] ?? '1');
if ((cell.attributes['rowspan'] ?? '1') != '1') {
throw const FormatException('考试记录行合并结构无法识别');
}
if (span == 1) {
cells.add(value);
} else if (span == 2 &&
cells.length == 4 &&
RegExp(r'^[\[【]?考试情况尚未发布[\]】]?$').hasMatch(value)) {
// EAMS merges the room and seat columns until it publishes them.
cells.addAll([value, '']);
} else {
throw const FormatException('考试记录列合并结构无法识别');
}
}
if (cells.length != 9) {
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(),
);
}
}