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 {
const UnsupportedSchoolLayout({
this.code = 'layout_unknown',
this.row,
this.column,
});
final String code;
final int? row, column;
String get description => switch (code) {
'origin_invalid' => '页面来源不符合学校课表要求',
'payload_too_large' => '课表页面超过读取大小限制',
'table_missing' => '未找到课表表格',
'semester_missing' => '课表学期信息缺失',
'row_count_invalid' => '课表时段行数不符合预期',
'weekday_header_invalid' => '星期表头格式不符合预期',
'row_cell_count_invalid' => '课表时段单元格不完整',
'period_header_invalid' => '课表节次表头无法识别',
'cell_id_invalid' => '课程单元格的星期或节次标识异常',
'course_record_invalid' => '课程名称、教师或课程字段格式无法识别',
'period_mismatch' => '课程节次与所在时段不一致',
'weeks_invalid' => '上课周次格式无法识别',
'unsafe_markup' => '课程单元格包含不支持的网页内容',
'empty_timetable' => '课表尚无可识别的课程安排',
_ => '学校课表格式暂不支持',
};
@override
String toString() => '$description($code),当前课表保持不变。请在反馈中附带学校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) {
throw const UnsupportedSchoolLayout(code: 'origin_invalid');
}
if (utf8.encode(content).length > 2 * 1024 * 1024) {
throw const UnsupportedSchoolLayout(code: 'payload_too_large');
}
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) {
throw const UnsupportedSchoolLayout(code: 'table_missing');
}
if (term == null ||
!RegExp(r'^\d+$').hasMatch(term) ||
selected!.text.trim().isEmpty) {
throw const UnsupportedSchoolLayout(code: 'semester_missing');
}
final rows = table.querySelectorAll('tr');
const weekdays = ['节次', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
if (rows.length != 6) {
throw const UnsupportedSchoolLayout(code: 'row_count_invalid');
}
if (rows.first.children.length != 8) {
throw const UnsupportedSchoolLayout(
code: 'weekday_header_invalid',
row: 0,
);
}
for (var i = 0; i < 8; i++) {
if (rows.first.children[i].text.trim() != weekdays[i]) {
throw UnsupportedSchoolLayout(
code: 'weekday_header_invalid',
row: 0,
column: i,
);
}
}
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) {
throw UnsupportedSchoolLayout(
code: 'row_cell_count_invalid',
row: row + 1,
);
}
if (cells.first.id != 't0$first' ||
cells.first.text.trim() != '$first-$last节') {
throw UnsupportedSchoolLayout(
code: 'period_header_invalid',
row: row + 1,
);
}
for (var day = 1; day <= 7; day++) {
final cell = cells[day];
if (cell.id != 't$day$first') {
throw UnsupportedSchoolLayout(
code: 'cell_id_invalid',
row: row + 1,
column: day,
);
}
final List lines;
try {
lines = _lines(cell);
} on UnsupportedSchoolLayout catch (error) {
throw UnsupportedSchoolLayout(
code: error.code,
row: row + 1,
column: day,
);
}
for (final line in lines) {
final record = RegExp(
r'^([A-Za-z0-9][A-Za-z0-9_.-]*)\s*/(.+?)/\s*(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)\s*/([^/]+)/([^/]+)周/(\d+)[~~-](\d+)节/(.*)$',
).firstMatch(line);
if (record == null) {
throw UnsupportedSchoolLayout(
code: 'course_record_invalid',
row: row + 1,
column: day,
);
}
if (int.parse(record[7]!) != first ||
int.parse(record[8]!) != last) {
throw UnsupportedSchoolLayout(
code: 'period_mismatch',
row: row + 1,
column: day,
);
}
final code = record[1]!,
name = record[2]!.trim(),
teacher = record[5]!.trim(),
rawRoom = record[9]!.trim(),
room = rawRoom.isEmpty ? '教室待定' : rawRoom;
if (name.isEmpty || teacher.isEmpty) {
throw UnsupportedSchoolLayout(
code: 'course_record_invalid',
row: row + 1,
column: day,
);
}
final Set weeks;
try {
weeks = _weeks(record[6]!);
} on UnsupportedSchoolLayout catch (error) {
throw UnsupportedSchoolLayout(
code: error.code,
row: row + 1,
column: day,
);
}
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,
notes: rawRoom.isEmpty ? '学校未提供教室,请以学校最新安排为准并在公布后刷新课表。' : '',
);
}
}
}
// An empty DOM can be an expired session or an incomplete response.
if (courses.isEmpty) {
throw const UnsupportedSchoolLayout(code: 'empty_timetable');
}
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 const UnsupportedSchoolLayout(code: 'unsafe_markup');
}
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 const UnsupportedSchoolLayout(code: 'weeks_invalid');
}
final first = int.parse(range[1]!),
last = int.parse(range[2] ?? range[1]!);
if (first < 1 || last > 30 || last < first) {
throw const UnsupportedSchoolLayout(code: 'weeks_invalid');
}
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!;
}
}