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
+3 -1
View File
@@ -1,6 +1,6 @@
# TPSchedule # TPSchedule
学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.6.2+17,Android 7.0 及以上。 学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.0+18,Android 7.0 及以上。
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
@@ -24,6 +24,8 @@
0.6.2将学校连接入口改为「选择学校」,目前提供「电子科技大学(研究生)」。选中后沿用原认证及导入,取消不开始登录;旧学校学期和连接刷新继续复用。详见[0.6.2学校选择验证记录](docs/tpschedule-0.6.2-validation.md)。 0.6.2将学校连接入口改为「选择学校」,目前提供「电子科技大学(研究生)」。选中后沿用原认证及导入,取消不开始登录;旧学校学期和连接刷新继续复用。详见[0.6.2学校选择验证记录](docs/tpschedule-0.6.2-validation.md)。
0.7.0新增「电子科技大学(本科生)」。本科课表由学生在学校网页选择学期、打开课表,再点击「导入当前课表」;考试支持一个学期及多种考试类型整批读取。查询历史本科考试保留当前课表。本科12节,第12节22:00–22:45;研究生及示例仍为11节。学校选择与成功查询选项持久保存,刷新沿用;详见[本科接入验证记录](docs/tpschedule-0.7.0-validation.md)。
0.5.4源码和安装包已冻结用于登记,位于dist/backups/TPSchedule-0.5.4-registration-20261014,Git标签tpschedule-0.5.4-registration。软著材料登记版本按用户要求为V1.0,使用冻结源码,未混入0.5.5功能,另存于output/pdf/TPSchedule-软著登记材料-V1.0。 0.5.4源码和安装包已冻结用于登记,位于dist/backups/TPSchedule-0.5.4-registration-20261014,Git标签tpschedule-0.5.4-registration。软著材料登记版本按用户要求为V1.0,使用冻结源码,未混入0.5.5功能,另存于output/pdf/TPSchedule-软著登记材料-V1.0。
## 安装与学校登录 ## 安装与学校登录
+2
View File
@@ -1,5 +1,7 @@
# Android 首版验证记录 # Android 首版验证记录
当前本科接入版为0.7.0+18,验证见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。 日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。
## 已验证 ## 已验证
+13 -1
View File
@@ -1,4 +1,16 @@
# 学校接入状态 · RSchedule 0.3.1 # 学校接入状态 · TPSchedule 0.7.0
## 本科接入
学校列表现有电子科技大学(研究生)、电子科技大学(本科生),连接目标使用稳定标识并保存本机。研究生沿用专用SSO及自动读取;本科使用独立连接器,学校浏览器均延迟到真正访问时创建。
本科课表从 `https://eams.uestc.edu.cn/eams/` 登录,学生自行打开课表、选择学期,再点击「导入当前课表」。同源框架及实际学期字段用于解析;停课周不生成课程,完整教师/教室、跨周变更与备注保留。新课表学期需设置开始日;同学校同学期更新保留日期和偏好。本科12节,第12节22:00–22:45。
本科考试访问 `https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action?semester.id={id}&examType.id={type}`。523为2026–2027第一学期,相邻学期编号差20;1期末、2期中、3补考/缓考。选择一个学期和至少一种类型,全部成功后一次保存;失败、取消、超时保留旧数据。重新认证后全部类型重读,验证码等待不算超时。成功选项用于刷新;历史本科考试不改变课表及开始日。
真实本科课表附件已验证10门课程、18段安排、3段含第12节;考试附件4条属于503,未被默认523污染。仓库只保留脱敏样例。真实账号在线认证、回调和验证码仍需本人验证;附件和模拟回调不替代这项确认。
## 研究生接入说明(沿用原0.3.1流程)
专用入口:https://yjsjy.uestc.edu.cn/pyxx/jzsso/login。 专用入口:https://yjsjy.uestc.edu.cn/pyxx/jzsso/login。
课表查询:https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1。 课表查询:https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1。
+34
View File
@@ -0,0 +1,34 @@
# TPSchedule 0.7.0+18:本科生接入验证
2026-10-15。工程位于F:/program/app,原应用名、包名com.tpschedule.app和签名保持。
## 实现
- 学校列表新增电子科技大学(本科生),学校选择使用稳定标识保存;取消或保存失败不开始登录。
- 本科课表在学校原网页登录、选择学期并打开课表后,点击「导入当前课表」。课表刷新和更新学期沿用手动流程。HTML/HTM离线导入也支持本科结构。
- 本科考试选择一个学期、多选期末/期中/补考缓考,按类型1→2→3读取,全部成功才保存;失败、取消、超时保留旧数据。认证恢复时丢弃旧片段、整批重读。成功查询选项持久保存供刷新使用。
- 523对应2026–2027第一学期,每相邻学期编号差20。考试学期独立保存,历史本科考试不改变当前课程、课表学期和开始日。
- 本科12节,第12节22:00–22:45;研究生及示例11节。七天同屏,教师和教室完整显示,相同节数课程条等长;课程详情保留课程代码和备注。提醒和日历结束时间覆盖第12节。
- 旧保存格式仍为schemaVersion1,新增字段兼容默认研究生;学校/学期损坏和本科第12节来源缺失拒绝保存或恢复。页面变动/取消时在保存队列内部再检查有效性,避免旧回调写入。
- Android实际检查发现网页加载前的一次状态探测可能读到旧空白文档;已补回归并改为加载完成后探测,再重建发布和调试包。
## 检查结果
- 完整Flutter套件241项通过;flutter analyze无问题。研究生及本科两个Node页面捕获脚本测试通过。
- 原本科课表附件解析10门课程、18段上课安排,3段包含第12节;逐节逐周对照保留全部有效安排,停课周扣除。原考试附件4条属于503,外层默认523没有污染实际学期。仓库仅保存脱敏等价样例。
- 核心回归覆盖人工登录等待超过120秒、单项45秒/整批120秒超时、最后一项失败、整批重新认证、关闭页面、过期回调、保存失败、旧研究生导入、来源损坏、第12节提醒/日历、换周动画、完整教师、课程条长度及慢启动。
- Android16/API36模拟器覆盖安装后11条原课程仍在。随后通过安卓文件选择器导入脱敏本科HTML:预览18条、提示新学期开始日、设置2026-09-07后显示12行及22:00–22:45;多教师完整显示。历史503考试导入4条,课表523、18条课程及开始日保持;考试选项取消不改成功选项,重启保持学校与数据。
- 发布包及调试包构建成功;包名com.tpschedule.app、versionName0.7.0、versionCode18。新旧证书逐字节相同,原私有签名文件摘要相同,登记冻结清单18项复核无变化。
- 模拟器使用软件兼容图形模式;未修改安装包的默认手机渲染设置。既有SDK XML版本提示仍出现,构建成功。
## 交付与实际边界
发布APK:59991887字节,SHA256 `D813F9FB6D33F4DE9A1116DF35EE66BBF8475E09DD1D536C50DFE017DFDBD1BE`。调试包、源码包及截图在dist中;保留TPSchedule-0.6.2.apk。
截图含学校列表、本科浏览器入口、考试选项、学期开始日提示、本科课表、12节及考试列表。真实本科账号、验证码、学校在线回调与自动查询尚未用本人账号验证;模拟器仅核对入口、离线数据与界面,不代表认证成功。需学生在学校原网页完成这部分验证,不要求在聊天中提供密码。
0.5.4登记冻结、V1.0材料、原签名以及反馈服务器部署文件均保持。反馈服务器仍由用户手动部署。
## 实现决策
保存接口增加可选页面有效性检查,并在串行保存队列内部执行;仅在界面检查会留下排队期间页面已变动的窗口。若误判会要求重新导入,原有调用保持兼容。最终独立审查结果记录于实现进度及后续交付记录。
+11 -11
View File
@@ -1,17 +1,17 @@
# hepan_everyday # TPSchedule Flutter工程
A new Flutter project. 当前版本0.7.0+18,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
## Getting Started ## 本科使用
This project is a starting point for a Flutter application. 在「我的 → 选择学校」选电子科技大学(本科生),通过学校原网页登录,打开课表并选学期,然后点击「导入当前课表」。导入新学校/学期后在课表页设置学期开始日。本科课表刷新和更新学期仍需在网页操作;本科考试页可选一个学期、多选期末/期中/补考缓考并自动读取。成功选项用于后续刷新,历史考试不改变课表。
A few resources to get you started if this is your first Flutter project: 本科显示12节,末节22:00–22:45;研究生和示例为11节。离线HTML/HTM及原JSON导入保持,教师/教室完整显示,课程名称详情可展开。
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) ## 开发与验证
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the 工具链、测试与构建入口见根目录README及 `scripts/android-env.ps1`、`scripts/build-android.ps1`。本目录运行 `flutter test --no-pub`、`flutter analyze --no-pub`;用Node运行 `test/school/*probe_js_test.cjs` 验证网页捕获。
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference. 本科连接模块在 `lib/features/school/undergraduate/`,学校标识在 `lib/domain/school_identity.dart`。HTML解析不执行网页脚本,样例已脱敏;真实账号、验证码及在线SSO需本人在学校网页验证。
不要删除、重建或分发根目录 `.tools/android-user/debug.keystore`;0.5.4登记冻结和V1.0材料不得用当前源码重生成。
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.6.2'; const appVersion = '0.7.0';
const appBuildNumber = 17; const appBuildNumber = 18;
+2 -2
View File
@@ -81,8 +81,7 @@ class _Shell extends StatefulWidget {
class _ShellState extends State<_Shell> with WidgetsBindingObserver { class _ShellState extends State<_Shell> with WidgetsBindingObserver {
int _tab = 0; int _tab = 0;
bool _openingSchool = false; bool _openingSchool = false;
// Earlier imports all belong to UESTC graduate; reuse their existing term // Restore the saved school; legacy graduate imports infer their source.
// and session without requiring students to choose again on every refresh.
SupportedSchool? get _knownSchool => SupportedSchool? get _knownSchool =>
widget.controller.snapshot.selectedSchool ?? widget.controller.snapshot.selectedSchool ??
(widget.undergraduate?.canLogout == true (widget.undergraduate?.canLogout == true
@@ -199,6 +198,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
chooseTerm: chooseTerm || term == null, chooseTerm: chooseTerm || term == null,
); );
} }
if (!mounted) return;
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute<void>(builder: (_) => widget.schoolBuilder!()), MaterialPageRoute<void>(builder: (_) => widget.schoolBuilder!()),
+6 -3
View File
@@ -8,13 +8,16 @@ import '../features/school/undergraduate/exam_adapter.dart';
class ExamFileImport { class ExamFileImport {
static ExamSchedule parse(String content) { static ExamSchedule parse(String content) {
if (utf8.encode(content).length > 2 * 1024 * 1024) if (utf8.encode(content).length > 2 * 1024 * 1024) {
throw const FormatException('文件不能超过2MB'); throw const FormatException('文件不能超过2MB');
}
final doc = html.parse(content.replaceFirst('\uFEFF', '')); final doc = html.parse(content.replaceFirst('\uFEFF', ''));
if (doc.querySelector('#stdExamTableForm') != null) if (doc.querySelector('#stdExamTableForm') != null) {
return UndergraduateExamAdapter.extract(content); return UndergraduateExamAdapter.extract(content);
if (doc.querySelector('#sample-table-1') != null) }
if (doc.querySelector('#sample-table-1') != null) {
return SchoolExamAdapter.extract(content); return SchoolExamAdapter.extract(content);
}
throw const FormatException('未识别到学校排考页面'); throw const FormatException('未识别到学校排考页面');
} }
} }
+2 -1
View File
@@ -107,8 +107,9 @@ class ExamSchedule {
? SupportedSchool.uestcGraduate ? SupportedSchool.uestcGraduate
: SupportedSchool.fromId(json['schoolId'] as String); : SupportedSchool.fromId(json['schoolId'] as String);
final rawTypes = json['requestedExamTypes'] ?? <int>[]; final rawTypes = json['requestedExamTypes'] ?? <int>[];
if (rawTypes is! List || rawTypes.any((t) => t is! int || t < 1 || t > 3)) if (rawTypes is! List || rawTypes.any((t) => t is! int || t < 1 || t > 3)) {
throw const FormatException('考试类型无效'); throw const FormatException('考试类型无效');
}
final types = rawTypes.cast<int>().toSet(); final types = rawTypes.cast<int>().toSet();
SchoolTerm(int.parse(id), school: school); SchoolTerm(int.parse(id), school: school);
if (school == SupportedSchool.uestcUndergraduate && if (school == SupportedSchool.uestcUndergraduate &&
+2 -1
View File
@@ -39,8 +39,9 @@ class SchoolTerm {
} }
void _graduateOnly() { void _graduateOnly() {
if (school != SupportedSchool.uestcGraduate) if (school != SupportedSchool.uestcGraduate) {
throw StateError('本科查询不能使用研究生链接'); throw StateError('本科查询不能使用研究生链接');
}
} }
Uri get timetableUri { Uri get timetableUri {
@@ -21,8 +21,9 @@ class UndergraduateExamQuery {
required this.term, required this.term,
required Set<UndergraduateExamType> types, required Set<UndergraduateExamType> types,
}) : types = Set.unmodifiable(types) { }) : types = Set.unmodifiable(types) {
if (term.school != SupportedSchool.uestcUndergraduate || types.isEmpty) if (term.school != SupportedSchool.uestcUndergraduate || types.isEmpty) {
throw ArgumentError('请选择本科学期和考试类型'); throw ArgumentError('请选择本科学期和考试类型');
}
} }
final SchoolTerm term; final SchoolTerm term;
final Set<UndergraduateExamType> types; final Set<UndergraduateExamType> types;
@@ -26,13 +26,15 @@ List<UndergraduateActivity> parseActivityData(
final m = matches[i], args = _strings(m[1]!); final m = matches[i], args = _strings(m[1]!);
if (args.length != 7) throw const FormatException('课程活动字段不完整'); if (args.length != 7) throw const FormatException('课程活动字段不完整');
final suffix = RegExp(r'\(([A-Za-z0-9_.-]+)\)$').firstMatch(args[3]); 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('教师、教室或课程序号缺失'); throw const FormatException('教师、教室或课程序号缺失');
}
final bitmap = args[6]; final bitmap = args[6];
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) || if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
bitmap.length > 64 || bitmap.length > 64 ||
(bitmap.length > 31 && bitmap.substring(31).contains('1'))) (bitmap.length > 31 && bitmap.substring(31).contains('1'))) {
throw const FormatException('上课周次超出1–30周'); throw const FormatException('上课周次超出1–30周');
}
final weeks = { final weeks = {
for (var w = 1; w < bitmap.length && w <= 30; w++) for (var w = 1; w < bitmap.length && w <= 30; w++)
if (bitmap[w] == '1') w, if (bitmap[w] == '1') w,
@@ -49,15 +51,17 @@ List<UndergraduateActivity> parseActivityData(
final appends = RegExp( final appends = RegExp(
r'table\d+\.activities\[index\]\s*\[[^;]+?\]\s*=\s*activity\s*;', r'table\d+\.activities\[index\]\s*\[[^;]+?\]\s*=\s*activity\s*;',
).allMatches(body).length; ).allMatches(body).length;
if (assignments.isEmpty || assignments.length != appends) if (assignments.isEmpty || assignments.length != appends) {
throw const FormatException('课程节次赋值不完整'); throw const FormatException('课程节次赋值不完整');
}
for (final assignment in assignments) { for (final assignment in assignments) {
final expression = RegExp(r'^\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)\s*$') final expression = RegExp(r'^\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)\s*$')
.firstMatch(assignment[1]!); .firstMatch(assignment[1]!);
if (expression == null) throw const FormatException('不支持的节次表达式'); if (expression == null) throw const FormatException('不支持的节次表达式');
final day = int.parse(expression[1]!), period = int.parse(expression[2]!); final day = int.parse(expression[1]!), period = int.parse(expression[2]!);
if (day > 6 || period >= unitCount) if (day > 6 || period >= unitCount) {
throw const FormatException('课程星期或节次无效'); throw const FormatException('课程星期或节次无效');
}
indices.add(day * unitCount + period); indices.add(day * unitCount + period);
} }
result.add( result.add(
@@ -113,8 +117,9 @@ List<String> _strings(String input) {
final count = c == 'u' ? 4 : 2; final count = c == 'u' ? 4 : 2;
if (p + count > input.length) throw const FormatException('字符串转义无效'); if (p + count > input.length) throw const FormatException('字符串转义无效');
final hex = input.substring(p, p + count); 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('字符串转义无效'); throw const FormatException('字符串转义无效');
}
b.writeCharCode(int.parse(hex, radix: 16)); b.writeCharCode(int.parse(hex, radix: 16));
p += count; p += count;
} else if (escapes.containsKey(c)) { } else if (escapes.containsKey(c)) {
@@ -98,6 +98,7 @@ class _UndergraduateAuthPageState extends State<UndergraduateAuthPage> {
} }
if (!current()) return; if (!current()) return;
if (saved && s.saved(generation: generation)) { if (saved && s.saved(generation: generation)) {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
if (route?.isCurrent == true) Navigator.pop(context); if (route?.isCurrent == true) Navigator.pop(context);
messenger.showSnackBar( messenger.showSnackBar(
@@ -26,11 +26,13 @@ class UndergraduateExamAdapter {
UndergraduateExamType? expectedType, UndergraduateExamType? expectedType,
}) { }) {
try { try {
if (utf8.encode(content).length > 2 * 1024 * 1024) if (utf8.encode(content).length > 2 * 1024 * 1024) {
throw const FormatException('考试页面超过2MB'); throw const FormatException('考试页面超过2MB');
}
final doc = html.parse(content); final doc = html.parse(content);
if (doc.querySelector('input[type="password"]') != null) if (doc.querySelector('input[type="password"]') != null) {
throw const FormatException('请先完成学校登录'); throw const FormatException('请先完成学校登录');
}
final form = doc.querySelector('#stdExamTableForm'); final form = doc.querySelector('#stdExamTableForm');
final scope = form ?? doc; final scope = form ?? doc;
final termValues = scope final termValues = scope
@@ -58,22 +60,26 @@ class UndergraduateExamAdapter {
final query = Uri.splitQueryString( final query = Uri.splitQueryString(
m[1]!.replaceFirst(RegExp(r'^&'), ''), m[1]!.replaceFirst(RegExp(r'^&'), ''),
); );
if (query['semester.id'] != null) if (query['semester.id'] != null) {
termValues.add(query['semester.id']!); termValues.add(query['semester.id']!);
if (query['examType.id'] != null) }
if (query['examType.id'] != null) {
typeValues.add(query['examType.id']!); typeValues.add(query['examType.id']!);
}
} }
} }
if (termValues.length != 1 || typeValues.length != 1) if (termValues.length != 1 || typeValues.length != 1) {
throw const FormatException('排考结果学期或类型不明确'); throw const FormatException('排考结果学期或类型不明确');
}
final term = SchoolTerm( final term = SchoolTerm(
int.parse(termValues.single), int.parse(termValues.single),
school: SupportedSchool.uestcUndergraduate, school: SupportedSchool.uestcUndergraduate,
); );
final type = UndergraduateExamType.fromId(int.parse(typeValues.single)); final type = UndergraduateExamType.fromId(int.parse(typeValues.single));
if (expectedTerm != null && expectedTerm != term || if (expectedTerm != null && expectedTerm != term ||
expectedType != null && expectedType != type) expectedType != null && expectedType != type) {
throw const FormatException('排考结果与所选学期或类型不一致'); throw const FormatException('排考结果与所选学期或类型不一致');
}
Element? table; Element? table;
for (final candidate in doc.querySelectorAll('table')) { for (final candidate in doc.querySelectorAll('table')) {
final cells = candidate final cells = candidate
@@ -116,12 +122,14 @@ class UndergraduateExamAdapter {
examTypeId: rowType, examTypeId: rowType,
notes: cells[8], notes: cells[8],
); );
if (record.code.isEmpty || record.name.isEmpty) if (record.code.isEmpty || record.name.isEmpty) {
throw const FormatException('考试课程缺失'); throw const FormatException('考试课程缺失');
}
records.putIfAbsent(record.undergraduateKey, () => record); records.putIfAbsent(record.undergraduateKey, () => record);
} }
if (records.isEmpty && !explicitEmpty) if (records.isEmpty && !explicitEmpty) {
throw const FormatException('尚未识别到明确的排考结果'); throw const FormatException('尚未识别到明确的排考结果');
}
return ExamSchedule.fromJson( return ExamSchedule.fromJson(
ExamSchedule( ExamSchedule(
semesterLabel: term.label, semesterLabel: term.label,
@@ -158,14 +166,16 @@ class UndergraduateExamAdapter {
part.termId != '${query.term.id}' || part.termId != '${query.term.id}' ||
part.requestedExamTypes.length != 1 || part.requestedExamTypes.length != 1 ||
!expected.contains(part.requestedExamTypes.single) || !expected.contains(part.requestedExamTypes.single) ||
!seen.add(part.requestedExamTypes.single)) !seen.add(part.requestedExamTypes.single)) {
throw const FormatException('考试查询批次不一致'); throw const FormatException('考试查询批次不一致');
}
for (final record in part.records) { for (final record in part.records) {
records.putIfAbsent(record.undergraduateKey, () => record); records.putIfAbsent(record.undergraduateKey, () => record);
} }
} }
if (seen.length != expected.length || records.length > 500) if (seen.length != expected.length || records.length > 500) {
throw const FormatException('考试未全部读取完成或记录超限'); throw const FormatException('考试未全部读取完成或记录超限');
}
return ExamSchedule.fromJson( return ExamSchedule.fromJson(
ExamSchedule( ExamSchedule(
semesterLabel: query.term.label, semesterLabel: query.term.label,
@@ -82,32 +82,37 @@ class UndergraduatePageEvidence {
!UndergraduateNavigationPolicy.canNavigate( !UndergraduateNavigationPolicy.canNavigate(
current, current,
isMainFrame: true, isMainFrame: true,
)) )) {
throw const FormatException('页面证据无效'); throw const FormatException('页面证据无效');
}
final pages = value['pages']; final pages = value['pages'];
if (pages is! List || pages.length > 32) if (pages is! List || pages.length > 32) {
throw const FormatException('页面片段过多'); throw const FormatException('页面片段过多');
}
var bytes = 0; var bytes = 0;
final result = <UndergraduateFragment>[]; final result = <UndergraduateFragment>[];
for (final page in pages) { for (final page in pages) {
if (page is! Map || if (page is! Map ||
page['url'] is! String || page['url'] is! String ||
page['html'] is! String || page['html'] is! String ||
!const ['timetable', 'exams'].contains(page['kind'])) !const ['timetable', 'exams'].contains(page['kind'])) {
throw const FormatException('页面片段无效'); throw const FormatException('页面片段无效');
}
final uri = Uri.parse(page['url'] as String), final uri = Uri.parse(page['url'] as String),
body = page['html'] as String; body = page['html'] as String;
bytes += utf8.encode(body).length; bytes += utf8.encode(body).length;
if (bytes > 2 * 1024 * 1024 || if (bytes > 2 * 1024 * 1024 ||
!UndergraduateNavigationPolicy.isDataPage(current) || !UndergraduateNavigationPolicy.isDataPage(current) ||
!UndergraduateNavigationPolicy.isDataPage(uri) || !UndergraduateNavigationPolicy.isDataPage(uri) ||
current.origin != uri.origin) current.origin != uri.origin) {
throw const FormatException('页面来源或大小无效'); throw const FormatException('页面来源或大小无效');
}
result.add(UndergraduateFragment(page['kind'] as String, uri, body)); result.add(UndergraduateFragment(page['kind'] as String, uri, body));
} }
if (!UndergraduateNavigationPolicy.isDataPage(current) && if (!UndergraduateNavigationPolicy.isDataPage(current) &&
(value['authenticated'] == true || result.isNotEmpty)) (value['authenticated'] == true || result.isNotEmpty)) {
throw const FormatException('认证页面不能作为数据来源'); throw const FormatException('认证页面不能作为数据来源');
}
return UndergraduatePageEvidence( return UndergraduatePageEvidence(
value['authenticated'] == true, value['authenticated'] == true,
List.unmodifiable(result), List.unmodifiable(result),
@@ -102,8 +102,9 @@ class UndergraduateSession extends ChangeNotifier {
return NavigationDecision.prevent; return NavigationDecision.prevent;
} }
if (request.isMainFrame && if (request.isMainFrame &&
UndergraduateNavigationPolicy.isLogout(uri)) UndergraduateNavigationPolicy.isLogout(uri)) {
invalidateAuthentication(); invalidateAuthentication();
}
return NavigationDecision.navigate; return NavigationDecision.navigate;
}, },
onPageStarted: (_) { onPageStarted: (_) {
@@ -123,8 +124,9 @@ class UndergraduateSession extends ChangeNotifier {
final g = _generation, final g = _generation,
d = _document, d = _document,
current = await web.currentUrl(); current = await web.currentUrl();
if (_valid(g) && d == _document && error.url == current) if (_valid(g) && d == _document && error.url == current) {
_failure('学校页面加载未完成,请检查网络后重试。'); _failure('学校页面加载未完成,请检查网络后重试。');
}
}, },
onHttpError: (error) async { onHttpError: (error) async {
final g = _generation, final g = _generation,
@@ -132,8 +134,9 @@ class UndergraduateSession extends ChangeNotifier {
current = await web.currentUrl(); current = await web.currentUrl();
if (_valid(g) && if (_valid(g) &&
d == _document && d == _document &&
error.request?.uri.toString() == current) error.request?.uri.toString() == current) {
_failure('学校请求未完成,请稍后重试。'); _failure('学校请求未完成,请稍后重试。');
}
}, },
), ),
); );
@@ -184,7 +187,7 @@ class UndergraduateSession extends ChangeNotifier {
return; return;
} }
_active = true; _active = true;
if (hasOpened && !_complete && !_failed) { if (hasOpened && !_loading && !_complete && !_failed) {
unawaited( unawaited(
web.currentUrl().then((url) { web.currentUrl().then((url) {
if (url != null && _active) _loaded(url); if (url != null && _active) _loaded(url);
@@ -216,8 +219,9 @@ class UndergraduateSession extends ChangeNotifier {
void _schedulePoll(int g, int document, String url) { void _schedulePoll(int g, int document, String url) {
_poll?.cancel(); _poll?.cancel();
_poll = Timer(const Duration(milliseconds: 450), () { _poll = Timer(const Duration(milliseconds: 450), () {
if (_valid(g) && _document == document && !_failed && !_complete) if (_valid(g) && _document == document && !_failed && !_complete) {
unawaited(_loaded(url)); unawaited(_loaded(url));
}
}); });
} }
@@ -237,8 +241,9 @@ class UndergraduateSession extends ChangeNotifier {
if (!_valid(g) || d != _document || current != url) return; if (!_valid(g) || d != _document || current != url) return;
final uri = Uri.tryParse(url); final uri = Uri.tryParse(url);
if (uri == null || if (uri == null ||
!UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true)) !UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true)) {
return; return;
}
final status = await _evidence(uri, false); final status = await _evidence(uri, false);
if (!_valid(g) || d != _document) return; if (!_valid(g) || d != _document) return;
_loading = false; _loading = false;
@@ -319,8 +324,9 @@ class UndergraduateSession extends ChangeNotifier {
final evidence = await _evidence(current, true); final evidence = await _evidence(current, true);
if (!_valid(g) || d != _document) return; if (!_valid(g) || d != _document) return;
final pages = evidence.pages.where((p) => p.kind == 'timetable').toList(); final pages = evidence.pages.where((p) => p.kind == 'timetable').toList();
if (pages.length != 1) if (pages.length != 1) {
throw const FormatException('请先在学校网页打开完整课表,再点击导入。'); throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
}
final draft = UndergraduateTimetableAdapter.extract( final draft = UndergraduateTimetableAdapter.extract(
pages.single.html, pages.single.html,
pages.single.url, pages.single.url,
@@ -345,8 +351,9 @@ class UndergraduateSession extends ChangeNotifier {
} }
bool saved({required int generation}) { bool saved({required int generation}) {
if (!_valid(generation) || pendingDraft == null && pendingExams == null) if (!_valid(generation) || pendingDraft == null && pendingExams == null) {
return false; return false;
}
pendingDraft = null; pendingDraft = null;
pendingExams = null; pendingExams = null;
message = '已导入'; message = '已导入';
@@ -21,14 +21,16 @@ class UndergraduateTimetableAdapter {
uri.userInfo.isNotEmpty || uri.userInfo.isNotEmpty ||
!uri.path.startsWith('/eams/') || !uri.path.startsWith('/eams/') ||
source == CourseSource.demo || source == CourseSource.demo ||
utf8.encode(content).length > 2 * 1024 * 1024) utf8.encode(content).length > 2 * 1024 * 1024) {
throw const FormatException('不是本科课表页面'); throw const FormatException('不是本科课表页面');
}
final doc = html.parse(content), final doc = html.parse(content),
table = html table = html
.parse(content) .parse(content)
.querySelector('#manualArrangeCourseTable'); .querySelector('#manualArrangeCourseTable');
if (table == null || doc.querySelector('input[type="password"]') != null) if (table == null || doc.querySelector('input[type="password"]') != null) {
throw const FormatException('请先在学校网页打开课表'); throw const FormatException('请先在学校网页打开课表');
}
final termIds = doc final termIds = doc
.querySelectorAll('input[name="semester.id"]') .querySelectorAll('input[name="semester.id"]')
.map((e) => e.attributes['value']) .map((e) => e.attributes['value'])
@@ -51,8 +53,9 @@ class UndergraduateTimetableAdapter {
: <UndergraduateActivity>[]; : <UndergraduateActivity>[];
final explicitEmpty = final explicitEmpty =
activities.isEmpty && RegExp(r'暂无课程|无课程安排|没有课程').hasMatch(table.text); activities.isEmpty && RegExp(r'暂无课程|无课程安排|没有课程').hasMatch(table.text);
if (activities.isEmpty && !explicitEmpty) if (activities.isEmpty && !explicitEmpty) {
throw const FormatException('课表尚未加载完整'); throw const FormatException('课表尚未加载完整');
}
final roster = <String, ({String code, String notes})>{}; final roster = <String, ({String code, String notes})>{};
for (final grid in doc.querySelectorAll('table')) { for (final grid in doc.querySelectorAll('table')) {
final rows = grid.querySelectorAll('tr'); final rows = grid.querySelectorAll('tr');
@@ -64,8 +67,9 @@ class UndergraduateTimetableAdapter {
note = headers.indexOf('选课备注'); note = headers.indexOf('选课备注');
for (final row in rows.skip(1)) { for (final row in rows.skip(1)) {
final cells = row.children; final cells = row.children;
if (cells.length != headers.length) if (cells.length != headers.length) {
throw const FormatException('课程名单不完整'); throw const FormatException('课程名单不完整');
}
roster[cells[seq].text.trim()] = ( roster[cells[seq].text.trim()] = (
code: cells[code].text.trim(), code: cells[code].text.trim(),
notes: note >= 0 ? cells[note].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('没有识别到有效上课安排'); throw const FormatException('没有识别到有效上课安排');
}
return ImportDraft( return ImportDraft(
courses: List.unmodifiable(courses), courses: List.unmodifiable(courses),
explicitlyEmpty: explicitEmpty, explicitlyEmpty: explicitEmpty,
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday name: hepan_everyday
description: TPSchedule 学生课表 description: TPSchedule 学生课表
publish_to: none publish_to: none
version: 0.6.2+17 version: 0.7.0+18
environment: environment:
sdk: ^3.13.5 sdk: ^3.13.5
dependencies: dependencies:
@@ -68,8 +68,7 @@ void main() {
); );
test('only recognized explicit empty exam tables are empty success', () { test('only recognized explicit empty exam tables are empty success', () {
final empty = final empty =
'<form id="stdExamTableForm"><input name="semester.id" value="503"><input name="examType.id" value="1"></form><table><tr>' + '<form id="stdExamTableForm"><input name="semester.id" value="503"><input name="examType.id" value="1"></form><table><tr>${[
[
'课程序号', '课程序号',
'课程名称', '课程名称',
'考试日期', '考试日期',
@@ -79,8 +78,7 @@ void main() {
'考试情况', '考试情况',
'考试类型', '考试类型',
'其它说明', '其它说明',
].map((s) => '<td>$s</td>').join() + ].map((s) => '<td>$s</td>').join()}</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
'</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
expect(UndergraduateExamAdapter.extract(empty).records, isEmpty); expect(UndergraduateExamAdapter.extract(empty).records, isEmpty);
expect( expect(
() => UndergraduateExamAdapter.extract(empty.replaceAll('暂无考试安排', '')), () => UndergraduateExamAdapter.extract(empty.replaceAll('暂无考试安排', '')),
@@ -92,7 +90,7 @@ void main() {
); );
}); });
final original = Platform.environment['UESTC_UNDERGRADUATE_EXAM_FIXTURE']; final original = Platform.environment['UESTC_UNDERGRADUATE_EXAM_FIXTURE'];
if (original != null) if (original != null) {
test('actual supplied exam export belongs to 503', () { test('actual supplied exam export belongs to 503', () {
final result = UndergraduateExamAdapter.extract( final result = UndergraduateExamAdapter.extract(
File(original).readAsStringSync(), File(original).readAsStringSync(),
@@ -100,4 +98,5 @@ void main() {
expect(result.records, hasLength(4)); expect(result.records, hasLength(4));
expect(result.termId, '503'); expect(result.termId, '503');
}); });
}
} }
@@ -31,6 +31,26 @@ void main() {
session = UndergraduateSession(); session = UndergraduateSession();
}); });
tearDown(() => session.dispose()); tearDown(() => session.dispose());
test(
'activating a loading browser never probes the previous blank document',
() async {
await session.openTimetable();
var probes = 0;
platform.web.javascript = (_) async {
probes++;
return underEvidence('about:blank', authenticated: false);
};
session.setReadingActive(true);
await flushSchoolEvents();
expect(probes, 0);
platform.web.javascript = (_) async => underEvidence(platform.web.url!);
platform.delegate.started(platform.web.url!);
platform.delegate.finished(platform.web.url!);
await flushSchoolEvents();
expect(session.busy, isFalse);
expect(session.message, UndergraduateSession.manualMessage);
},
);
test( test(
'manual session is lazy and never captures until the student taps import', 'manual session is lazy and never captures until the student taps import',
() async { () async {
@@ -124,7 +124,7 @@ void main() {
}); });
final original = final original =
Platform.environment['UESTC_UNDERGRADUATE_TIMETABLE_FIXTURE']; Platform.environment['UESTC_UNDERGRADUATE_TIMETABLE_FIXTURE'];
if (original != null) if (original != null) {
test( test(
'actual supplied undergraduate HTML has complete normalized arrangements', 'actual supplied undergraduate HTML has complete normalized arrangements',
() { () {
@@ -141,4 +141,5 @@ void main() {
); );
}, },
); );
}
} }
@@ -46,6 +46,7 @@ void main() {
.readAsStringSync() .readAsStringSync()
: null, : null,
); );
platform.delegate.finished(platform.web.url!);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Builder( home: Builder(