feat: deliver Riverside Schedule seven-day grid and school HTML import

This commit is contained in:
Codex
2026-10-13 11:18:50 +08:00
parent 91a2522043
commit 8403be05da
29 changed files with 1246 additions and 446 deletions
@@ -92,7 +92,7 @@ class _SchoolAuthPageState extends State<SchoolAuthPage> {
),
actions: [
IconButton(
tooltip: _showWeb ? '河畔登录界面' : '学校页面',
tooltip: _showWeb ? '应用登录界面' : '学校页面',
onPressed: () => setState(() => _showWeb = !_showWeb),
icon: Icon(
_showWeb ? Icons.person_outline : Icons.language_rounded,
@@ -195,7 +195,7 @@ class _SchoolAuthPageState extends State<SchoolAuthPage> {
),
const SizedBox(height: 20),
Text(
'密码仅交给学校处理,不在河畔保存。需要验证码或其他验证时,将由学校页面继续处理。',
'密码仅交给学校处理,不在应用中保存。需要验证码或其他验证时,将由学校页面继续处理。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
@@ -12,7 +12,10 @@ class SchoolNavigationPolicy {
static bool isTimetable(Uri uri) =>
_https(uri) &&
uri.host == 'yjsjy.uestc.edu.cn' &&
uri.path == '/pyxx/pygl/xskbcx/index/1';
RegExp(
r'^/pyxx/pygl/xskbcx/index/[1-9]\d*'
r'$',
).hasMatch(uri.path);
static bool isSchoolPage(Uri uri) =>
_https(uri) &&
const {'idas.uestc.edu.cn', 'yjsjy.uestc.edu.cn'}.contains(uri.host);
@@ -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!;
+17 -4
View File
@@ -35,6 +35,9 @@ class SchoolSession extends ChangeNotifier {
return NavigationDecision.navigate;
},
onPageStarted: (_) {
_pageGeneration++;
_reader.invalidate();
pendingDraft = null;
if (!hasOpened) {
return;
}
@@ -72,6 +75,7 @@ class SchoolSession extends ChangeNotifier {
hasOpened = false,
formReady = false;
bool get busy => _loading || _submitting || _reader.busy;
int _pageGeneration = 0;
ImportDraft? pendingDraft;
String message = '请通过学校统一身份认证登录。';
String get statusLabel => switch (connectionState) {
@@ -79,7 +83,7 @@ class SchoolSession extends ChangeNotifier {
SchoolConnectionState.authenticating => '请在学校页面完成认证',
SchoolConnectionState.connected => '学校认证已确认',
SchoolConnectionState.expired => '学校登录已过期',
SchoolConnectionState.unknown => '学校页面已打开 · 课表识别待联调',
SchoolConnectionState.unknown => '学校页面已打开 · 等待完整课表',
};
static final authUrl = Uri.parse(
'https://idas.uestc.edu.cn/authserver/login',
@@ -134,26 +138,33 @@ class SchoolSession extends ChangeNotifier {
return;
}
_loading = false;
final generation = _pageGeneration;
final uri = Uri.tryParse(url);
if (uri != null && SchoolNavigationPolicy.canInjectCredentials(uri)) {
connectionState = connectionState == SchoolConnectionState.connected
? SchoolConnectionState.expired
: SchoolConnectionState.authenticating;
try {
formReady = _truth(
final ready = _truth(
await web.runJavaScriptReturningResult(_readyScript),
);
if (!hasOpened || generation != _pageGeneration) return;
formReady = ready;
} catch (_) {
if (!hasOpened || generation != _pageGeneration) return;
formReady = false;
}
message = formReady ? '可以使用河畔界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。';
message = formReady ? '可以使用应用界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。';
} else if (uri != null && SchoolNavigationPolicy.isTimetable(uri)) {
connectionState = SchoolConnectionState.unknown;
message = '已打开课表页面,正在核对课程结构。';
try {
pendingDraft = await readTimetable();
final draft = await readTimetable();
if (!hasOpened || generation != _pageGeneration) return;
pendingDraft = draft;
connectionState = SchoolConnectionState.connected;
} catch (error) {
if (!hasOpened || generation != _pageGeneration) return;
message = error is UnsupportedSchoolLayout
? error.toString()
: '课表读取未完成,已有课表保持不变。';
@@ -238,6 +249,8 @@ class SchoolSession extends ChangeNotifier {
}
Future<void> logout() async {
_pageGeneration++;
_reader.invalidate();
hasOpened = false;
await WebViewCookieManager().clearCookies();
await web.clearCache();