Files
TPSchedule/mobile/lib/features/school/school_page_adapter.dart
T

313 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = <String, Course>{};
final ids = <String>{};
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<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'^([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<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,
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<String> _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<int> _weeks(String text) {
final result = <int>{};
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<ImportDraft>? _pending;
int _generation = 0;
Uri? _origin;
bool get busy => _pending != null;
void invalidate() {
_generation++;
_pending = null;
_origin = null;
}
Future<ImportDraft> read(Uri origin, Future<String> 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<ImportDraft>(() 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!;
}
}