190 lines
6.7 KiB
Dart
190 lines
6.7 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)) {
|
|
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(),
|
|
);
|
|
}
|
|
}
|