fix: import unpublished exam rooms and add opt-in school HTML feedback
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:html/parser.dart' as html;
|
||||
|
||||
/// An opt-in mail attachment, separate from persistent structured logs.
|
||||
/// Keeps only course/exam evidence, never the surrounding account/login page.
|
||||
class SchoolPageDiagnostic {
|
||||
SchoolPageDiagnostic({DateTime Function()? clock})
|
||||
: clock = clock ?? DateTime.now;
|
||||
static final instance = SchoolPageDiagnostic();
|
||||
static const maxBytes = 2 * 1024 * 1024;
|
||||
static const marker = '<!doctype html><!-- TPSchedule school diagnostic -->';
|
||||
final DateTime Function() clock;
|
||||
Uint8List? _bytes;
|
||||
DateTime? _at;
|
||||
Timer? _expiry;
|
||||
|
||||
void clear() {
|
||||
_expiry?.cancel();
|
||||
_expiry = null;
|
||||
_bytes = null;
|
||||
_at = null;
|
||||
}
|
||||
|
||||
Uint8List? export() {
|
||||
final at = _at;
|
||||
if (at == null ||
|
||||
clock().difference(at).isNegative ||
|
||||
clock().difference(at) >= const Duration(minutes: 10)) {
|
||||
clear();
|
||||
}
|
||||
return _bytes == null ? null : Uint8List.fromList(_bytes!);
|
||||
}
|
||||
|
||||
void remember(String content, Uri uri) {
|
||||
clear();
|
||||
try {
|
||||
if (uri.scheme != 'https' ||
|
||||
uri.host != 'eams.uestc.edu.cn' ||
|
||||
uri.port != 443 ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
!uri.path.startsWith('/eams/') ||
|
||||
utf8.encode(content).length > maxBytes) {
|
||||
return;
|
||||
}
|
||||
final doc = html.parse(content);
|
||||
if (doc.querySelector('input[type="password"]') != null) return;
|
||||
final tables = doc
|
||||
.querySelectorAll('table')
|
||||
.where(
|
||||
(t) =>
|
||||
t.id == 'manualArrangeCourseTable' ||
|
||||
t.querySelector('tr')?.text.contains('课程序号') == true,
|
||||
)
|
||||
.toList();
|
||||
if (tables.isEmpty) return;
|
||||
final fields = <String, Set<String>>{};
|
||||
void field(String key, String value) {
|
||||
if (const ['semester.id', 'examType.id'].contains(key) &&
|
||||
RegExp(r'^\d{1,6}$').hasMatch(value)) {
|
||||
fields.putIfAbsent(key, () => {}).add(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (final input in doc.querySelectorAll('input')) {
|
||||
field(input.attributes['name'] ?? '', input.attributes['value'] ?? '');
|
||||
}
|
||||
for (final node in doc.querySelectorAll('script,input[name="params"]')) {
|
||||
final value = node.localName == 'input'
|
||||
? node.attributes['value'] ?? ''
|
||||
: node.text;
|
||||
for (final m in RegExp(
|
||||
r'(semester\.id|examType\.id)=(\d{1,6})(?=[&"\x27\s]|$)',
|
||||
).allMatches(value)) {
|
||||
field(m[1]!, m[2]!);
|
||||
}
|
||||
}
|
||||
final script = doc
|
||||
.querySelectorAll('script')
|
||||
.map((s) => s.text)
|
||||
.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);
|
||||
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',
|
||||
)) {
|
||||
node.remove();
|
||||
}
|
||||
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';
|
||||
}
|
||||
for (final key in ['rowspan', 'colspan']) {
|
||||
final value = attrs[key];
|
||||
if (value != null && RegExp(r'^\d{1,2}$').hasMatch(value)) {
|
||||
node.attributes[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
final safe =
|
||||
'$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>'
|
||||
'${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>';
|
||||
final bytes = Uint8List.fromList(utf8.encode(safe));
|
||||
if (bytes.length > maxBytes) return;
|
||||
_bytes = bytes;
|
||||
_at = clock();
|
||||
_expiry = Timer(const Duration(minutes: 10), clear);
|
||||
} catch (_) {
|
||||
clear(); // Diagnostic collection must not interfere with importing.
|
||||
}
|
||||
}
|
||||
|
||||
// Split outside quoted strings without validating the course arguments: a
|
||||
// malformed argument/index is precisely what this attachment must preserve.
|
||||
static String _courseStatements(String script) {
|
||||
final out = <String>[];
|
||||
var start = 0;
|
||||
String? quote;
|
||||
var lineComment = false, blockComment = false;
|
||||
final allowed = RegExp(
|
||||
r'^(?:var\s+)?(?:unitCount\s*=|activity\s*=\s*new\s+TaskActivity\b|new\s+TaskActivity\b|index\s*=|table\d+\.activities\s*\[)',
|
||||
);
|
||||
void keep(int end) {
|
||||
final statement = script
|
||||
.substring(start, end)
|
||||
.replaceFirst(
|
||||
RegExp(r'^(?:\s|//[^\r\n]*(?:\r?\n|$)|/\*[\s\S]*?\*/)*'),
|
||||
'',
|
||||
)
|
||||
.trim();
|
||||
if (allowed.hasMatch(statement)) out.add(statement);
|
||||
start = end;
|
||||
}
|
||||
|
||||
for (var i = 0; i < script.length; i++) {
|
||||
final c = script[i];
|
||||
final next = i + 1 < script.length ? script[i + 1] : '';
|
||||
if (lineComment) {
|
||||
if (c == '\n' || c == '\r') lineComment = false;
|
||||
} else if (blockComment) {
|
||||
if (c == '*' && next == '/') {
|
||||
blockComment = false;
|
||||
i++;
|
||||
}
|
||||
} else if (quote != null) {
|
||||
if (c == '\\') {
|
||||
i++;
|
||||
} else if (c == quote) {
|
||||
quote = null;
|
||||
}
|
||||
} else if (c == '/' && next == '/') {
|
||||
lineComment = true;
|
||||
i++;
|
||||
} else if (c == '/' && next == '*') {
|
||||
blockComment = true;
|
||||
i++;
|
||||
} else if (c == '"' || c == "'" || c == '`') {
|
||||
quote = c;
|
||||
} else if (c == ';') {
|
||||
keep(i + 1);
|
||||
}
|
||||
}
|
||||
if (start < script.length) keep(script.length);
|
||||
return out.join('\n');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user