feat: deliver Riverside Schedule seven-day grid and school HTML import
This commit is contained in:
@@ -1,16 +1,177 @@
|
||||
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 'school_navigation_policy.dart';
|
||||
|
||||
class UnsupportedSchoolLayout implements Exception {
|
||||
@override
|
||||
String toString() => '学校课表识别待联调,当前课表保持不变。';
|
||||
String toString() => '未识别到完整的学校课表,当前课表保持不变。请在课表查询页面保存 HTML 后重试。';
|
||||
}
|
||||
|
||||
/// Reads the supplied graduate-system table as data; never executes its scripts.
|
||||
class SchoolPageAdapter {
|
||||
ImportDraft extract(String html, Uri source) {
|
||||
// A real authenticated timetable fixture is required before registering a parser.
|
||||
// Unknown HTML must never erase the student's last valid imported timetable.
|
||||
throw UnsupportedSchoolLayout();
|
||||
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 = <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 ||
|
||||
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(),
|
||||
);
|
||||
} 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 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<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 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,17 +179,36 @@ 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();
|
||||
}
|
||||
return adapter.extract(await getHtml(), origin);
|
||||
final content = await getHtml();
|
||||
if (generation != _generation) throw UnsupportedSchoolLayout();
|
||||
return adapter.extract(content, origin);
|
||||
} finally {
|
||||
_pending = null;
|
||||
if (generation == _generation) {
|
||||
_pending = null;
|
||||
_origin = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
return _pending!;
|
||||
|
||||
Reference in New Issue
Block a user