feat: release undergraduate integration 0.7.0

This commit is contained in:
Codex
2026-10-15 01:14:06 +08:00
parent b28f333720
commit 674df1ceb3
22 changed files with 173 additions and 63 deletions
@@ -26,13 +26,15 @@ List<UndergraduateActivity> parseActivityData(
final m = matches[i], args = _strings(m[1]!);
if (args.length != 7) throw const FormatException('课程活动字段不完整');
final suffix = RegExp(r'\(([A-Za-z0-9_.-]+)\)$').firstMatch(args[3]);
if (suffix == null || args[1].trim().isEmpty || args[5].trim().isEmpty)
if (suffix == null || args[1].trim().isEmpty || args[5].trim().isEmpty) {
throw const FormatException('教师、教室或课程序号缺失');
}
final bitmap = args[6];
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
bitmap.length > 64 ||
(bitmap.length > 31 && bitmap.substring(31).contains('1')))
(bitmap.length > 31 && bitmap.substring(31).contains('1'))) {
throw const FormatException('上课周次超出1–30周');
}
final weeks = {
for (var w = 1; w < bitmap.length && w <= 30; w++)
if (bitmap[w] == '1') w,
@@ -49,15 +51,17 @@ List<UndergraduateActivity> parseActivityData(
final appends = RegExp(
r'table\d+\.activities\[index\]\s*\[[^;]+?\]\s*=\s*activity\s*;',
).allMatches(body).length;
if (assignments.isEmpty || assignments.length != appends)
if (assignments.isEmpty || assignments.length != appends) {
throw const FormatException('课程节次赋值不完整');
}
for (final assignment in assignments) {
final expression = RegExp(r'^\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)\s*$')
.firstMatch(assignment[1]!);
if (expression == null) throw const FormatException('不支持的节次表达式');
final day = int.parse(expression[1]!), period = int.parse(expression[2]!);
if (day > 6 || period >= unitCount)
if (day > 6 || period >= unitCount) {
throw const FormatException('课程星期或节次无效');
}
indices.add(day * unitCount + period);
}
result.add(
@@ -113,8 +117,9 @@ List<String> _strings(String input) {
final count = c == 'u' ? 4 : 2;
if (p + count > input.length) throw const FormatException('字符串转义无效');
final hex = input.substring(p, p + count);
if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(hex))
if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(hex)) {
throw const FormatException('字符串转义无效');
}
b.writeCharCode(int.parse(hex, radix: 16));
p += count;
} else if (escapes.containsKey(c)) {
@@ -98,6 +98,7 @@ class _UndergraduateAuthPageState extends State<UndergraduateAuthPage> {
}
if (!current()) return;
if (saved && s.saved(generation: generation)) {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
if (route?.isCurrent == true) Navigator.pop(context);
messenger.showSnackBar(
@@ -26,11 +26,13 @@ class UndergraduateExamAdapter {
UndergraduateExamType? expectedType,
}) {
try {
if (utf8.encode(content).length > 2 * 1024 * 1024)
if (utf8.encode(content).length > 2 * 1024 * 1024) {
throw const FormatException('考试页面超过2MB');
}
final doc = html.parse(content);
if (doc.querySelector('input[type="password"]') != null)
if (doc.querySelector('input[type="password"]') != null) {
throw const FormatException('请先完成学校登录');
}
final form = doc.querySelector('#stdExamTableForm');
final scope = form ?? doc;
final termValues = scope
@@ -58,22 +60,26 @@ class UndergraduateExamAdapter {
final query = Uri.splitQueryString(
m[1]!.replaceFirst(RegExp(r'^&'), ''),
);
if (query['semester.id'] != null)
if (query['semester.id'] != null) {
termValues.add(query['semester.id']!);
if (query['examType.id'] != null)
}
if (query['examType.id'] != null) {
typeValues.add(query['examType.id']!);
}
}
}
if (termValues.length != 1 || typeValues.length != 1)
if (termValues.length != 1 || typeValues.length != 1) {
throw const FormatException('排考结果学期或类型不明确');
}
final term = SchoolTerm(
int.parse(termValues.single),
school: SupportedSchool.uestcUndergraduate,
);
final type = UndergraduateExamType.fromId(int.parse(typeValues.single));
if (expectedTerm != null && expectedTerm != term ||
expectedType != null && expectedType != type)
expectedType != null && expectedType != type) {
throw const FormatException('排考结果与所选学期或类型不一致');
}
Element? table;
for (final candidate in doc.querySelectorAll('table')) {
final cells = candidate
@@ -116,12 +122,14 @@ class UndergraduateExamAdapter {
examTypeId: rowType,
notes: cells[8],
);
if (record.code.isEmpty || record.name.isEmpty)
if (record.code.isEmpty || record.name.isEmpty) {
throw const FormatException('考试课程缺失');
}
records.putIfAbsent(record.undergraduateKey, () => record);
}
if (records.isEmpty && !explicitEmpty)
if (records.isEmpty && !explicitEmpty) {
throw const FormatException('尚未识别到明确的排考结果');
}
return ExamSchedule.fromJson(
ExamSchedule(
semesterLabel: term.label,
@@ -158,14 +166,16 @@ class UndergraduateExamAdapter {
part.termId != '${query.term.id}' ||
part.requestedExamTypes.length != 1 ||
!expected.contains(part.requestedExamTypes.single) ||
!seen.add(part.requestedExamTypes.single))
!seen.add(part.requestedExamTypes.single)) {
throw const FormatException('考试查询批次不一致');
}
for (final record in part.records) {
records.putIfAbsent(record.undergraduateKey, () => record);
}
}
if (seen.length != expected.length || records.length > 500)
if (seen.length != expected.length || records.length > 500) {
throw const FormatException('考试未全部读取完成或记录超限');
}
return ExamSchedule.fromJson(
ExamSchedule(
semesterLabel: query.term.label,
@@ -82,32 +82,37 @@ class UndergraduatePageEvidence {
!UndergraduateNavigationPolicy.canNavigate(
current,
isMainFrame: true,
))
)) {
throw const FormatException('页面证据无效');
}
final pages = value['pages'];
if (pages is! List || pages.length > 32)
if (pages is! List || pages.length > 32) {
throw const FormatException('页面片段过多');
}
var bytes = 0;
final result = <UndergraduateFragment>[];
for (final page in pages) {
if (page is! Map ||
page['url'] is! String ||
page['html'] is! String ||
!const ['timetable', 'exams'].contains(page['kind']))
!const ['timetable', 'exams'].contains(page['kind'])) {
throw const FormatException('页面片段无效');
}
final uri = Uri.parse(page['url'] as String),
body = page['html'] as String;
bytes += utf8.encode(body).length;
if (bytes > 2 * 1024 * 1024 ||
!UndergraduateNavigationPolicy.isDataPage(current) ||
!UndergraduateNavigationPolicy.isDataPage(uri) ||
current.origin != uri.origin)
current.origin != uri.origin) {
throw const FormatException('页面来源或大小无效');
}
result.add(UndergraduateFragment(page['kind'] as String, uri, body));
}
if (!UndergraduateNavigationPolicy.isDataPage(current) &&
(value['authenticated'] == true || result.isNotEmpty))
(value['authenticated'] == true || result.isNotEmpty)) {
throw const FormatException('认证页面不能作为数据来源');
}
return UndergraduatePageEvidence(
value['authenticated'] == true,
List.unmodifiable(result),
@@ -102,8 +102,9 @@ class UndergraduateSession extends ChangeNotifier {
return NavigationDecision.prevent;
}
if (request.isMainFrame &&
UndergraduateNavigationPolicy.isLogout(uri))
UndergraduateNavigationPolicy.isLogout(uri)) {
invalidateAuthentication();
}
return NavigationDecision.navigate;
},
onPageStarted: (_) {
@@ -123,8 +124,9 @@ class UndergraduateSession extends ChangeNotifier {
final g = _generation,
d = _document,
current = await web.currentUrl();
if (_valid(g) && d == _document && error.url == current)
if (_valid(g) && d == _document && error.url == current) {
_failure('学校页面加载未完成,请检查网络后重试。');
}
},
onHttpError: (error) async {
final g = _generation,
@@ -132,8 +134,9 @@ class UndergraduateSession extends ChangeNotifier {
current = await web.currentUrl();
if (_valid(g) &&
d == _document &&
error.request?.uri.toString() == current)
error.request?.uri.toString() == current) {
_failure('学校请求未完成,请稍后重试。');
}
},
),
);
@@ -184,7 +187,7 @@ class UndergraduateSession extends ChangeNotifier {
return;
}
_active = true;
if (hasOpened && !_complete && !_failed) {
if (hasOpened && !_loading && !_complete && !_failed) {
unawaited(
web.currentUrl().then((url) {
if (url != null && _active) _loaded(url);
@@ -216,8 +219,9 @@ class UndergraduateSession extends ChangeNotifier {
void _schedulePoll(int g, int document, String url) {
_poll?.cancel();
_poll = Timer(const Duration(milliseconds: 450), () {
if (_valid(g) && _document == document && !_failed && !_complete)
if (_valid(g) && _document == document && !_failed && !_complete) {
unawaited(_loaded(url));
}
});
}
@@ -237,8 +241,9 @@ class UndergraduateSession extends ChangeNotifier {
if (!_valid(g) || d != _document || current != url) return;
final uri = Uri.tryParse(url);
if (uri == null ||
!UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true))
!UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true)) {
return;
}
final status = await _evidence(uri, false);
if (!_valid(g) || d != _document) return;
_loading = false;
@@ -319,8 +324,9 @@ class UndergraduateSession extends ChangeNotifier {
final evidence = await _evidence(current, true);
if (!_valid(g) || d != _document) return;
final pages = evidence.pages.where((p) => p.kind == 'timetable').toList();
if (pages.length != 1)
if (pages.length != 1) {
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
}
final draft = UndergraduateTimetableAdapter.extract(
pages.single.html,
pages.single.url,
@@ -345,8 +351,9 @@ class UndergraduateSession extends ChangeNotifier {
}
bool saved({required int generation}) {
if (!_valid(generation) || pendingDraft == null && pendingExams == null)
if (!_valid(generation) || pendingDraft == null && pendingExams == null) {
return false;
}
pendingDraft = null;
pendingExams = null;
message = '已导入';
@@ -21,14 +21,16 @@ class UndergraduateTimetableAdapter {
uri.userInfo.isNotEmpty ||
!uri.path.startsWith('/eams/') ||
source == CourseSource.demo ||
utf8.encode(content).length > 2 * 1024 * 1024)
utf8.encode(content).length > 2 * 1024 * 1024) {
throw const FormatException('不是本科课表页面');
}
final doc = html.parse(content),
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
.querySelectorAll('input[name="semester.id"]')
.map((e) => e.attributes['value'])
@@ -51,8 +53,9 @@ class UndergraduateTimetableAdapter {
: <UndergraduateActivity>[];
final explicitEmpty =
activities.isEmpty && RegExp(r'暂无课程|无课程安排|没有课程').hasMatch(table.text);
if (activities.isEmpty && !explicitEmpty)
if (activities.isEmpty && !explicitEmpty) {
throw const FormatException('课表尚未加载完整');
}
final roster = <String, ({String code, String notes})>{};
for (final grid in doc.querySelectorAll('table')) {
final rows = grid.querySelectorAll('tr');
@@ -64,8 +67,9 @@ class UndergraduateTimetableAdapter {
note = headers.indexOf('选课备注');
for (final row in rows.skip(1)) {
final cells = row.children;
if (cells.length != headers.length)
if (cells.length != headers.length) {
throw const FormatException('课程名单不完整');
}
roster[cells[seq].text.trim()] = (
code: cells[code].text.trim(),
notes: note >= 0 ? cells[note].text.trim() : '',
@@ -162,8 +166,9 @@ class UndergraduateTimetableAdapter {
);
}
}
if (courses.isEmpty && !explicitEmpty)
if (courses.isEmpty && !explicitEmpty) {
throw const FormatException('没有识别到有效上课安排');
}
return ImportDraft(
courses: List.unmodifiable(courses),
explicitlyEmpty: explicitEmpty,