Preserve courses with pending rooms and report precise graduate import errors
This commit is contained in:
@@ -9,8 +9,32 @@ 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() => '未识别到完整的学校课表,当前课表保持不变。请在课表查询页面保存 HTML 后重试。';
|
||||
String toString() => '$description($code),当前课表保持不变。请在反馈中附带学校HTML和日志。';
|
||||
}
|
||||
|
||||
/// Reads the supplied graduate-system table as data; never executes its scripts.
|
||||
@@ -22,28 +46,42 @@ class SchoolPageAdapter {
|
||||
}) {
|
||||
try {
|
||||
if (!SchoolNavigationPolicy.isTimetable(origin) ||
|
||||
source == CourseSource.demo ||
|
||||
utf8.encode(content).length > 2 * 1024 * 1024) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
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 ||
|
||||
term == null ||
|
||||
if (table == null) {
|
||||
throw const UnsupportedSchoolLayout(code: 'table_missing');
|
||||
}
|
||||
if (term == null ||
|
||||
!RegExp(r'^\d+$').hasMatch(term) ||
|
||||
selected!.text.trim().isEmpty) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
throw const UnsupportedSchoolLayout(code: 'semester_missing');
|
||||
}
|
||||
final rows = table.querySelectorAll('tr');
|
||||
const weekdays = ['节次', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
|
||||
if (rows.length != 6 || rows.first.children.length != 8) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
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();
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: 'weekday_header_invalid',
|
||||
row: 0,
|
||||
column: i,
|
||||
);
|
||||
}
|
||||
}
|
||||
final courses = <String, Course>{};
|
||||
@@ -52,28 +90,79 @@ class SchoolPageAdapter {
|
||||
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' ||
|
||||
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();
|
||||
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();
|
||||
for (final line in _lines(cell)) {
|
||||
if (cell.id != 't$day$first') {
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: 'cell_id_invalid',
|
||||
row: row + 1,
|
||||
column: day,
|
||||
);
|
||||
}
|
||||
final List<String> 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'^(\d+)/(.+?)/(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)/([^/]+)/([^/]+)周/(\d+)[~~-](\d+)节/(.+)$',
|
||||
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 ||
|
||||
int.parse(record[7]!) != first ||
|
||||
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();
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: 'period_mismatch',
|
||||
row: row + 1,
|
||||
column: day,
|
||||
);
|
||||
}
|
||||
final code = record[1]!,
|
||||
name = record[2]!.trim(),
|
||||
teacher = record[5]!.trim(),
|
||||
room = record[9]!.trim();
|
||||
final weeks = _weeks(record[6]!);
|
||||
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<int> 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,
|
||||
@@ -101,12 +190,15 @@ class SchoolPageAdapter {
|
||||
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 UnsupportedSchoolLayout();
|
||||
if (courses.isEmpty) {
|
||||
throw const UnsupportedSchoolLayout(code: 'empty_timetable');
|
||||
}
|
||||
return ImportDraft(
|
||||
courses: List.unmodifiable(courses.values),
|
||||
explicitlyEmpty: false,
|
||||
@@ -132,7 +224,7 @@ class SchoolPageAdapter {
|
||||
buffer.write('\n');
|
||||
} else {
|
||||
if (node.localName == 'script' || node.localName == 'style') {
|
||||
throw UnsupportedSchoolLayout();
|
||||
throw const UnsupportedSchoolLayout(code: 'unsafe_markup');
|
||||
}
|
||||
for (final child in node.nodes) {
|
||||
visit(child);
|
||||
@@ -157,11 +249,13 @@ class SchoolPageAdapter {
|
||||
for (final part in text.split(RegExp('[,,、]'))) {
|
||||
final range = RegExp(r'^\s*(\d+)(?:\s*[-~~]\s*(\d+))?\s*$')
|
||||
.firstMatch(part);
|
||||
if (range == null) throw UnsupportedSchoolLayout();
|
||||
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 UnsupportedSchoolLayout();
|
||||
throw const UnsupportedSchoolLayout(code: 'weeks_invalid');
|
||||
}
|
||||
result.addAll(List.generate(last - first + 1, (i) => first + i));
|
||||
}
|
||||
|
||||
@@ -174,6 +174,8 @@ class SchoolSession extends ChangeNotifier {
|
||||
bool _routeClosed = false;
|
||||
int _pollsLeft = 0;
|
||||
int? _probeGeneration;
|
||||
UnsupportedSchoolLayout? _lastLayoutFailure;
|
||||
int? _layoutFailureGeneration;
|
||||
SchoolConnectionState connectionState = SchoolConnectionState.signedOut;
|
||||
bool _loading = false,
|
||||
hasOpened = false,
|
||||
@@ -334,9 +336,16 @@ class SchoolSession extends ChangeNotifier {
|
||||
String get _findingMessage => requestingExams ? '正在查找考试安排,请稍候' : '正在查找课表,请稍候';
|
||||
void _armTimeout() {
|
||||
_timeout?.cancel();
|
||||
_timeout = Timer(
|
||||
const Duration(seconds: 45),
|
||||
() => _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout'),
|
||||
_timeout = Timer(const Duration(seconds: 45), _finishReadFailure);
|
||||
}
|
||||
|
||||
void _finishReadFailure() {
|
||||
final error = _layoutFailureGeneration == _pageGeneration
|
||||
? _lastLayoutFailure
|
||||
: null;
|
||||
_failure(
|
||||
error == null ? '查找超时,请检查学校页面后重试。已有数据保持不变。' : '课表读取未完成:$error',
|
||||
code: error?.code ?? 'read_timeout',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,7 +405,7 @@ class SchoolSession extends ChangeNotifier {
|
||||
}
|
||||
_poll?.cancel();
|
||||
if (_pollsLeft-- <= 0) {
|
||||
if (finding) _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout');
|
||||
if (finding) _finishReadFailure();
|
||||
return;
|
||||
}
|
||||
_poll = Timer(const Duration(milliseconds: 750), () {
|
||||
@@ -500,6 +509,8 @@ class SchoolSession extends ChangeNotifier {
|
||||
// gets only its recognized fragment with the canonical table path.
|
||||
final draft = await _reader.read(_target, () async => page.html);
|
||||
if (!hasOpened || generation != _pageGeneration) return;
|
||||
_lastLayoutFailure = null;
|
||||
_layoutFailureGeneration = null;
|
||||
if (draft.schoolTerm != selectedTerm) {
|
||||
DiagnosticLog.instance.add(
|
||||
'term_mismatch',
|
||||
@@ -528,11 +539,24 @@ class SchoolSession extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (generation != _pageGeneration) return;
|
||||
if (error is UnsupportedSchoolLayout) {
|
||||
_lastLayoutFailure = error;
|
||||
_layoutFailureGeneration = generation;
|
||||
message =
|
||||
'课表暂未识别:${error.description}。正在等待页面加载完整;若仍失败,请在反馈中附带学校HTML和日志。';
|
||||
}
|
||||
DiagnosticLog.instance.add(
|
||||
'school_parse_rejected',
|
||||
fields: {
|
||||
'school': 'graduate',
|
||||
'code': error.runtimeType.toString(),
|
||||
'code': error is UnsupportedSchoolLayout
|
||||
? error.code
|
||||
: error.runtimeType.toString(),
|
||||
if (error is UnsupportedSchoolLayout && error.row != null)
|
||||
'row': error.row,
|
||||
if (error is UnsupportedSchoolLayout && error.column != null)
|
||||
'column': error.column,
|
||||
},
|
||||
);
|
||||
if (generation != _pageGeneration) return;
|
||||
@@ -595,6 +619,7 @@ class SchoolSession extends ChangeNotifier {
|
||||
liveUrl != uri.toString()) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
UnsupportedSchoolLayout? rejected;
|
||||
for (final page in evidence.pages) {
|
||||
if (page.kind != 'timetable') continue;
|
||||
SchoolPageDiagnostic.instance.remember(page.html, page.url);
|
||||
@@ -608,11 +633,12 @@ class SchoolSession extends ChangeNotifier {
|
||||
}
|
||||
if (draft.schoolTerm != selectedTerm) continue;
|
||||
return draft;
|
||||
} on UnsupportedSchoolLayout {
|
||||
} on UnsupportedSchoolLayout catch (error) {
|
||||
if (generation != _pageGeneration) rethrow;
|
||||
rejected = error;
|
||||
}
|
||||
}
|
||||
throw UnsupportedSchoolLayout();
|
||||
throw rejected ?? const UnsupportedSchoolLayout(code: 'table_missing');
|
||||
}
|
||||
|
||||
Future<void> readCurrentPage() async {
|
||||
|
||||
@@ -54,13 +54,6 @@ List<UndergraduateActivity> parseActivityData(
|
||||
i + 1,
|
||||
);
|
||||
}
|
||||
if (args[5].trim().isEmpty) {
|
||||
throw UndergraduateActivityException(
|
||||
'room_missing',
|
||||
'课程活动未提供教室信息,请附带网页反馈',
|
||||
i + 1,
|
||||
);
|
||||
}
|
||||
final bitmap = args[6];
|
||||
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
|
||||
bitmap.length > 64 ||
|
||||
@@ -109,7 +102,7 @@ List<UndergraduateActivity> parseActivityData(
|
||||
args[1].trim(),
|
||||
args[3].substring(0, suffix.start).trim(),
|
||||
suffix[1]!,
|
||||
args[5].trim(),
|
||||
args[5].trim().isEmpty ? '教室待定' : args[5].trim(),
|
||||
indices,
|
||||
weeks,
|
||||
),
|
||||
|
||||
@@ -103,7 +103,13 @@ class _UndergraduateAuthPageState extends State<UndergraduateAuthPage> {
|
||||
if (route?.isCurrent == true) Navigator.pop(context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(exams == null ? '课表已导入,请核对学期开始日。' : '考试信息已更新'),
|
||||
content: Text(
|
||||
exams != null
|
||||
? '考试信息已更新'
|
||||
: draft!.courses.any((c) => c.room == '教室待定')
|
||||
? '课表已导入,部分课程教室待定;请核对学期开始日。'
|
||||
: '课表已导入,请核对学期开始日。',
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (controller.lastError != null) {
|
||||
|
||||
@@ -28,7 +28,8 @@ class UndergraduateTimetableAdapter {
|
||||
table = html
|
||||
.parse(content)
|
||||
.querySelector('#manualArrangeCourseTable');
|
||||
if (table == null || doc.querySelector('input[type="password"]') != null) {
|
||||
if (table == null ||
|
||||
doc.querySelector('input[type="password"]') != null) {
|
||||
throw const FormatException('请先在学校网页打开课表');
|
||||
}
|
||||
final termIds = doc
|
||||
@@ -161,7 +162,10 @@ class UndergraduateTimetableAdapter {
|
||||
weeks: group.weeks,
|
||||
source: source,
|
||||
courseCode: info?.code ?? a.courseCode,
|
||||
notes: info?.notes ?? '',
|
||||
notes: [
|
||||
if (info?.notes.isNotEmpty == true) info!.notes,
|
||||
if (a.room == '教室待定') '学校未提供教室,请以学校最新安排为准并在公布后刷新课表。',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user