import 'dart:convert'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' as html; import '../../data/json_schedule_import.dart'; import '../../domain/course.dart'; import '../../domain/school_term.dart'; import 'school_navigation_policy.dart'; class UnsupportedSchoolLayout implements Exception { @override String toString() => '未识别到完整的学校课表,当前课表保持不变。请在课表查询页面保存 HTML 后重试。'; } /// Reads the supplied graduate-system table as data; never executes its scripts. class SchoolPageAdapter { ImportDraft extract( String content, Uri origin, { CourseSource source = CourseSource.school, }) { try { if (!SchoolNavigationPolicy.isTimetable(origin) || source == CourseSource.demo || utf8.encode(content).length > 2 * 1024 * 1024) { throw UnsupportedSchoolLayout(); } final document = html.parse(content); final table = document.querySelector('#tbl'); final selected = document.querySelector('#drpxq option[selected]'); final term = selected?.attributes['value']; if (table == null || term == null || !RegExp(r'^\d+$').hasMatch(term) || selected!.text.trim().isEmpty) { throw UnsupportedSchoolLayout(); } final rows = table.querySelectorAll('tr'); const weekdays = ['节次', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']; if (rows.length != 6 || rows.first.children.length != 8) { throw UnsupportedSchoolLayout(); } for (var i = 0; i < 8; i++) { if (rows.first.children[i].text.trim() != weekdays[i]) { throw UnsupportedSchoolLayout(); } } final courses = {}; final ids = {}; const starts = [1, 3, 5, 7, 9]; for (var row = 0; row < starts.length; row++) { final first = starts[row], last = first == 9 ? 11 : first + 1; final cells = rows[row + 1].children; if (cells.length != 8 || cells.first.id != 't0$first' || cells.first.text.trim() != '$first-$last节') { throw UnsupportedSchoolLayout(); } for (var day = 1; day <= 7; day++) { final cell = cells[day]; if (cell.id != 't$day$first') throw UnsupportedSchoolLayout(); for (final line in _lines(cell)) { final record = RegExp( r'^(\d+)/(.+?)/(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)/([^/]+)/([^/]+)周/(\d+)[~~-](\d+)节/(.+)$', ).firstMatch(line); if (record == null || int.parse(record[7]!) != first || int.parse(record[8]!) != last) { throw UnsupportedSchoolLayout(); } final code = record[1]!, name = record[2]!.trim(), teacher = record[5]!.trim(), room = record[9]!.trim(); final weeks = _weeks(record[6]!); final identity = jsonEncode([ term, code, name, teacher, room, day, first, last, ]); final previous = courses[identity]; final id = previous?.id ?? 'uestc-$term-$code-$day-$first-$last-${_hash(identity)}'; if (previous == null && !ids.add(id)) { throw UnsupportedSchoolLayout(); } courses[identity] = Course( id: id, name: name, teacher: teacher, room: room, weekday: day, startPeriod: first, endPeriod: last, weeks: {...?previous?.weeks, ...weeks}, source: source, ); } } } // An empty DOM can be an expired session or an incomplete response. if (courses.isEmpty) throw UnsupportedSchoolLayout(); return ImportDraft( courses: List.unmodifiable(courses.values), explicitlyEmpty: false, source: source, parserVersion: 'uestc-graduate-html-v1', semesterLabel: selected.text.trim(), schoolTerm: SchoolTerm(int.parse(term)), ); } on UnsupportedSchoolLayout { rethrow; } catch (_) { throw UnsupportedSchoolLayout(); } } List _lines(dom.Element cell) { final buffer = StringBuffer(); void visit(dom.Node node) { if (node is dom.Text) { buffer.write(node.text); } else if (node is dom.Element) { if (node.localName == 'br') { buffer.write('\n'); } else { if (node.localName == 'script' || node.localName == 'style') { throw UnsupportedSchoolLayout(); } for (final child in node.nodes) { visit(child); } } } } for (final node in cell.nodes) { visit(node); } return buffer .toString() .split('\n') .map((s) => s.trim()) .where((s) => s.isNotEmpty) .toList(); } Set _weeks(String text) { final result = {}; for (final part in text.split(RegExp('[,,、]'))) { final range = RegExp(r'^\s*(\d+)(?:\s*[-~~]\s*(\d+))?\s*$') .firstMatch(part); if (range == null) throw UnsupportedSchoolLayout(); final first = int.parse(range[1]!), last = int.parse(range[2] ?? range[1]!); if (first < 1 || last > 30 || last < first) { throw UnsupportedSchoolLayout(); } result.addAll(List.generate(last - first + 1, (i) => first + i)); } return result; } String _hash(String value) { var hash = 2166136261; for (final byte in utf8.encode(value)) { hash = ((hash ^ byte) * 16777619) & 0xffffffff; } return hash.toRadixString(16).padLeft(8, '0'); } } class SchoolReader { SchoolReader(this.adapter); final SchoolPageAdapter adapter; Future? _pending; int _generation = 0; Uri? _origin; bool get busy => _pending != null; void invalidate() { _generation++; _pending = null; _origin = null; } Future read(Uri origin, Future Function() getHtml) { if (!SchoolNavigationPolicy.isTimetable(origin) || (_pending != null && origin != _origin)) { return Future.error(UnsupportedSchoolLayout()); } if (_pending != null) return _pending!; final generation = _generation; _origin = origin; _pending = Future(() async { try { if (!SchoolNavigationPolicy.isTimetable(origin)) { throw UnsupportedSchoolLayout(); } final content = await getHtml(); if (generation != _generation) throw UnsupportedSchoolLayout(); return adapter.extract(content, origin); } finally { if (generation == _generation) { _pending = null; _origin = null; } } }); return _pending!; } }