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. /// 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 = ''; 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 { 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 || 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) => 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 = []; 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 = >{}; 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 = 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', )) { node.remove(); } for (final node in [table, ...table.querySelectorAll('*')]) { final attrs = Map.from(node.attributes); node.attributes.clear(); 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]; if (value != null && RegExp(r'^\d{1,2}$').hasMatch(value)) { node.attributes[key] = value; } } } } final safe = '$marker' '' 'TPSchedule school diagnostic' '${graduateTerms.map((t) => t.outerHtml).join()}' '${fields.entries.expand((e) => e.value.map((v) => '')).join()}' '${tables.map((t) => t.outerHtml).join()}' ''; 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 = []; 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'); } }