feat: parse and merge undergraduate exam schedules
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user