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(),
);
}
}