feat: extend optional HTML feedback to graduate imports in 0.7.8

This commit is contained in:
Codex
2026-10-24 15:15:29 +08:00
parent 9a198fe466
commit a946aa6ec0
24 changed files with 513 additions and 36 deletions
+3 -2
View File
@@ -1,2 +1,3 @@
const appVersion = '0.7.6';
const appBuildNumber = 24;
const appVersion = '0.7.8';
const appBuildNumber = 26;
const appFilingNumber = '蜀ICP备2025169276号-5A';
@@ -120,7 +120,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
_includeHtml &&
html == null) {
throw const FeedbackException(
'最近10分钟没有可用的本科导入页面。请返回学校页面再尝试导入,然后提交反馈;也可以取消附带HTML。',
'最近10分钟没有可用的学校导入页面。请返回本科或研究生学校页面再尝试导入,然后提交反馈;也可以取消附带HTML。',
);
}
_pendingSubmission ??= _kind == FeedbackKind.problem
@@ -304,9 +304,9 @@ class _FeedbackPageState extends State<FeedbackPage> {
CheckboxListTile(
key: const Key('feedback-html'),
contentPadding: EdgeInsets.zero,
title: const Text('附带最近一次本科导入页面HTML'),
title: const Text('附带最近一次学校导入页面HTML'),
subtitle: const Text(
'需先勾选日志。网页可能含姓名、学号、课程、教师和考场信息,仅保留最近10分钟,单独作为邮件附件发送。',
'适用于本科和研究生课表、考试导入。需先勾选日志。网页可能含姓名、学号、课程、教师和考场信息,仅保留最近10分钟,单独作为邮件附件发送。',
),
value: _includeHtml,
onChanged: _submitting || !_includeLogs
@@ -281,6 +281,14 @@ class ProfilePage extends StatelessWidget {
),
),
const SizedBox(height: 6),
Text(
appFilingNumber,
style: TextStyle(
fontSize: 11,
color: ink.withValues(alpha: 0.45),
),
),
const SizedBox(height: 6),
Text(
'今天也有值得期待的小事。',
style: TextStyle(
+17 -5
View File
@@ -7,6 +7,7 @@ import '../../data/json_schedule_import.dart';
import '../../domain/exam_schedule.dart';
import '../../domain/school_term.dart';
import '../../services/diagnostic_log.dart';
import '../../services/school_page_diagnostic.dart';
import 'school_navigation_policy.dart';
import 'school_page_adapter.dart';
import 'school_exam_adapter.dart';
@@ -257,6 +258,7 @@ class SchoolSession extends ChangeNotifier {
final closing = _logoutFuture;
if (closing != null) await closing;
if (serial != _openSerial) return;
SchoolPageDiagnostic.instance.clear();
_timeout?.cancel();
_poll?.cancel();
_manualRead = false;
@@ -303,6 +305,7 @@ class SchoolSession extends ChangeNotifier {
return;
}
final serial = ++_openSerial;
SchoolPageDiagnostic.instance.clear();
_poll?.cancel();
_timeout?.cancel();
_pageGeneration++;
@@ -460,9 +463,8 @@ class SchoolSession extends ChangeNotifier {
'term': selectedTerm?.id,
},
);
if (!hasOpened ||
generation != _pageGeneration ||
await web.currentUrl() != url) {
final liveUrl = await web.currentUrl();
if (!hasOpened || generation != _pageGeneration || liveUrl != url) {
return;
}
if (evidence.terms.isNotEmpty) _liveTerms = evidence.terms;
@@ -480,6 +482,7 @@ class SchoolSession extends ChangeNotifier {
}
for (final page in evidence.pages) {
if (page.kind != (requestingExams ? 'exams' : 'timetable')) continue;
SchoolPageDiagnostic.instance.remember(page.html, page.url);
try {
if (requestingExams) {
final draft = SchoolExamAdapter.extract(page.html);
@@ -586,17 +589,23 @@ class SchoolSession extends ChangeNotifier {
throw UnsupportedSchoolLayout();
}
final evidence = await _evidence(uri);
if (generation != _pageGeneration ||
await web.currentUrl() != uri.toString()) {
final liveUrl = await web.currentUrl();
if (!hasOpened ||
generation != _pageGeneration ||
liveUrl != uri.toString()) {
throw UnsupportedSchoolLayout();
}
for (final page in evidence.pages) {
if (page.kind != 'timetable') continue;
SchoolPageDiagnostic.instance.remember(page.html, page.url);
try {
final draft = await _reader.read(
selectedTerm!.timetableUri,
() async => page.html,
);
if (!hasOpened || generation != _pageGeneration) {
throw UnsupportedSchoolLayout();
}
if (draft.schoolTerm != selectedTerm) continue;
return draft;
} on UnsupportedSchoolLayout {
@@ -648,6 +657,7 @@ class SchoolSession extends ChangeNotifier {
}
void invalidateAuthentication() {
SchoolPageDiagnostic.instance.clear();
_openSerial++;
_pageGeneration++;
_reader.invalidate();
@@ -667,6 +677,7 @@ class SchoolSession extends ChangeNotifier {
}
Future<void> _performLogout() async {
SchoolPageDiagnostic.instance.clear();
_openSerial++;
_pageGeneration++;
_reader.invalidate();
@@ -697,6 +708,7 @@ class SchoolSession extends ChangeNotifier {
@override
void dispose() {
SchoolPageDiagnostic.instance.clear();
hasOpened = false;
_pageGeneration++;
_reader.invalidate();
+50 -10
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html;
/// An opt-in mail attachment, separate from persistent structured logs.
@@ -37,11 +38,18 @@ class SchoolPageDiagnostic {
void remember(String content, Uri uri) {
clear();
try {
if (uri.scheme != 'https' ||
uri.host != 'eams.uestc.edu.cn' ||
uri.port != 443 ||
final undergraduate =
uri.scheme == 'https' &&
uri.port == 443 &&
uri.host == 'eams.uestc.edu.cn' &&
uri.path.startsWith('/eams/');
final graduate =
uri.host == 'yjsjy.uestc.edu.cn' &&
(uri.scheme == 'https' && uri.port == 443 ||
uri.scheme == 'http' && uri.port == 80) &&
(uri.path == '/pyxx' || uri.path.startsWith('/pyxx/'));
if ((!undergraduate && !graduate) ||
uri.userInfo.isNotEmpty ||
!uri.path.startsWith('/eams/') ||
utf8.encode(content).length > maxBytes) {
return;
}
@@ -50,12 +58,35 @@ class SchoolPageDiagnostic {
final tables = doc
.querySelectorAll('table')
.where(
(t) =>
t.id == 'manualArrangeCourseTable' ||
t.querySelector('tr')?.text.contains('课程序号') == true,
(t) => graduate
? const ['tbl', 'sample-table-1'].contains(t.id)
: t.id == 'manualArrangeCourseTable' ||
t.querySelector('tr')?.text.contains('课程序号') == true,
)
.toList();
if (tables.isEmpty) return;
final graduateTerms = <dom.Element>[];
if (graduate) {
for (final table in tables) {
final id = table.id == 'tbl' ? 'drpxq' : 'xqbh';
final original = doc.querySelector('select#$id');
if (original == null) continue;
final select = dom.Element.tag('select')..id = id;
for (final originalOption in original.querySelectorAll('option')) {
final option = dom.Element.tag('option')
..text = originalOption.text;
final value = originalOption.attributes['value'];
if (value != null && RegExp(r'^\d{1,6}$').hasMatch(value)) {
option.attributes['value'] = value;
}
if (originalOption.attributes.containsKey('selected')) {
option.attributes['selected'] = '';
}
select.children.add(option);
}
graduateTerms.add(select);
}
}
final fields = <String, Set<String>>{};
void field(String key, String value) {
if (const ['semester.id', 'examType.id'].contains(key) &&
@@ -83,7 +114,7 @@ class SchoolPageDiagnostic {
.join('\n');
// Only retain the literal course statements our parser reads. All other
// scripts, URL-bearing attributes and form/account fields are omitted.
final statements = _courseStatements(script);
final statements = undergraduate ? _courseStatements(script) : '';
for (final table in tables) {
for (final node in table.querySelectorAll(
'script,style,link,iframe,object,embed,form,input,textarea,select,button,img,svg,video,audio,source,meta,base',
@@ -93,8 +124,16 @@ class SchoolPageDiagnostic {
for (final node in [table, ...table.querySelectorAll('*')]) {
final attrs = Map<String, String>.from(node.attributes);
node.attributes.clear();
if (attrs['id'] == 'manualArrangeCourseTable') {
node.id = 'manualArrangeCourseTable';
final id = attrs['id'];
if (node.localName == 'table' &&
(id == 'manualArrangeCourseTable' ||
graduate && const ['tbl', 'sample-table-1'].contains(id))) {
node.id = id!;
} else if (graduate &&
const ['td', 'th'].contains(node.localName) &&
id != null &&
RegExp(r'^t[0-7][13579]$').hasMatch(id)) {
node.id = id;
}
for (final key in ['rowspan', 'colspan']) {
final value = attrs[key];
@@ -108,6 +147,7 @@ class SchoolPageDiagnostic {
'$marker<html><head><meta charset="utf-8">'
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; base-uri \'none\'; form-action \'none\'">'
'<title>TPSchedule school diagnostic</title></head><body>'
'${graduateTerms.map((t) => t.outerHtml).join()}'
'${fields.entries.expand((e) => e.value.map((v) => '<input type="hidden" name="${e.key}" value="$v">')).join()}'
'${tables.map((t) => t.outerHtml).join()}'
'<script type="application/x-tpschedule-course-data">${statements.replaceAll('<', r'\u003c')}</script></body></html>';