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