diff --git a/mobile/lib/data/schedule_file_import.dart b/mobile/lib/data/schedule_file_import.dart index 15ffd2d..cf23bff 100644 --- a/mobile/lib/data/schedule_file_import.dart +++ b/mobile/lib/data/schedule_file_import.dart @@ -1,11 +1,19 @@ import 'json_schedule_import.dart'; import '../domain/course.dart'; import '../features/school/school_page_adapter.dart'; +import '../features/school/undergraduate/timetable_adapter.dart'; class ScheduleFileImport { static ImportDraft parse(String content) { final trimmed = content.replaceFirst('\uFEFF', '').trimLeft(); if (trimmed.startsWith('<')) { + if (trimmed.contains('manualArrangeCourseTable')) { + return UndergraduateTimetableAdapter.extract( + trimmed, + Uri.parse('https://eams.uestc.edu.cn/eams/courseTableForStd.action'), + source: CourseSource.file, + ); + } return SchoolPageAdapter().extract( trimmed, Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1'), diff --git a/mobile/lib/features/school/undergraduate/activity_data.dart b/mobile/lib/features/school/undergraduate/activity_data.dart new file mode 100644 index 0000000..66e1af4 --- /dev/null +++ b/mobile/lib/features/school/undergraduate/activity_data.dart @@ -0,0 +1,139 @@ +class UndergraduateActivity { + UndergraduateActivity( + this.teacher, + this.name, + this.courseCode, + this.room, + this.indices, + this.weeks, + ); + final String teacher, name, courseCode, room; + final Set indices, weeks; + bool get cancelled => room == '停课'; +} + +List parseActivityData( + String script, { + required int unitCount, +}) { + if (unitCount != 12) throw const FormatException('本科节次结构无效'); + final matches = RegExp( + r'new\s+TaskActivity\s*\((.*?)\)\s*;', + dotAll: true, + ).allMatches(script).toList(); + final result = []; + for (var i = 0; i < matches.length; i++) { + 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) + 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'))) + throw const FormatException('上课周次超出1–30周'); + final weeks = { + for (var w = 1; w < bitmap.length && w <= 30; w++) + if (bitmap[w] == '1') w, + }; + if (weeks.isEmpty) throw const FormatException('课程周次为空'); + var end = i + 1 < matches.length ? matches[i + 1].start : script.length; + final finish = script.indexOf('table0.marshalTable', m.end); + if (finish >= 0 && finish < end) end = finish; + final body = script.substring(m.end, end); + final indices = {}; + final assignments = RegExp(r'\bindex\s*=\s*([^;]+);') + .allMatches(body) + .toList(); + final appends = RegExp( + r'table\d+\.activities\[index\]\s*\[[^;]+?\]\s*=\s*activity\s*;', + ).allMatches(body).length; + 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) + throw const FormatException('课程星期或节次无效'); + indices.add(day * unitCount + period); + } + result.add( + UndergraduateActivity( + args[1].trim(), + args[3].substring(0, suffix.start).trim(), + suffix[1]!, + args[5].trim(), + indices, + weeks, + ), + ); + } + return result; +} + +/// Reads only quoted JS literals; expressions and calls are never evaluated. +List _strings(String input) { + final out = []; + var p = 0; + void spaces() { + while (p < input.length && RegExp(r'\s').hasMatch(input[p])) { + p++; + } + } + + while (true) { + spaces(); + if (p >= input.length) break; + final quote = input[p++]; + if (quote != '"' && quote != "'") throw const FormatException('活动参数必须为字符串'); + final b = StringBuffer(); + var closed = false; + while (p < input.length) { + var c = input[p++]; + if (c == quote) { + closed = true; + break; + } + if (c == '\\') { + if (p >= input.length) throw const FormatException('字符串转义不完整'); + c = input[p++]; + const escapes = { + 'n': '\n', + 'r': '\r', + 't': '\t', + 'b': '\b', + 'f': '\f', + 'v': '\u000b', + '0': '\u0000', + }; + if (c == 'u' || c == 'x') { + 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)) + throw const FormatException('字符串转义无效'); + b.writeCharCode(int.parse(hex, radix: 16)); + p += count; + } else if (escapes.containsKey(c)) { + b.write(escapes[c]); + } else if (const ['"', "'", '\\', '/'].contains(c)) { + b.write(c); + } else { + throw const FormatException('不支持的字符串转义'); + } + } else { + if (c == '\n' || c == '\r') throw const FormatException('字符串未闭合'); + b.write(c); + } + } + if (!closed) throw const FormatException('字符串未闭合'); + out.add(b.toString()); + spaces(); + if (p == input.length) break; + if (input[p++] != ',') throw const FormatException('活动字段分隔无效'); + } + return out; +} diff --git a/mobile/lib/features/school/undergraduate/timetable_adapter.dart b/mobile/lib/features/school/undergraduate/timetable_adapter.dart new file mode 100644 index 0000000..5e0c514 --- /dev/null +++ b/mobile/lib/features/school/undergraduate/timetable_adapter.dart @@ -0,0 +1,189 @@ +import 'dart:convert'; + +import 'package:html/parser.dart' as html; + +import '../../../data/json_schedule_import.dart'; +import '../../../domain/course.dart'; +import '../../../domain/school_identity.dart'; +import '../../../domain/school_term.dart'; +import 'activity_data.dart'; + +class UndergraduateTimetableAdapter { + static ImportDraft extract( + String content, + Uri uri, { + CourseSource source = CourseSource.school, + }) { + try { + if (uri.scheme != 'https' || + uri.host != 'eams.uestc.edu.cn' || + uri.port != 443 || + uri.userInfo.isNotEmpty || + !uri.path.startsWith('/eams/') || + source == CourseSource.demo || + 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) + throw const FormatException('请先在学校网页打开课表'); + final termIds = doc + .querySelectorAll('input[name="semester.id"]') + .map((e) => e.attributes['value']) + .toSet(); + if (termIds.length != 1) throw const FormatException('课表学期不明确'); + final term = SchoolTerm( + int.parse(termIds.single!), + school: SupportedSchool.uestcUndergraduate, + ); + final scripts = doc + .querySelectorAll('script') + .map((e) => e.text) + .join('\n'); + final units = RegExp(r'\b(?:var\s+)?unitCount\s*=\s*(\d+)\s*;') + .allMatches(scripts) + .map((m) => int.parse(m[1]!)) + .toSet(); + final activities = units.length == 1 + ? parseActivityData(scripts, unitCount: units.single) + : []; + final explicitEmpty = + activities.isEmpty && RegExp(r'暂无课程|无课程安排|没有课程').hasMatch(table.text); + if (activities.isEmpty && !explicitEmpty) + throw const FormatException('课表尚未加载完整'); + final roster = {}; + for (final grid in doc.querySelectorAll('table')) { + final rows = grid.querySelectorAll('tr'); + if (rows.isEmpty) continue; + final headers = rows.first.children.map((e) => e.text.trim()).toList(); + if (!headers.contains('课程序号') || !headers.contains('课程代码')) continue; + final seq = headers.indexOf('课程序号'), + code = headers.indexOf('课程代码'), + note = headers.indexOf('选课备注'); + for (final row in rows.skip(1)) { + final cells = row.children; + 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() : '', + ); + } + } + final cancellations = >{}; + for (final a in activities.where((a) => a.cancelled)) { + for (final index in a.indices) { + cancellations + .putIfAbsent('${a.courseCode}/$index', () => {}) + .addAll(a.weeks); + } + } + final cells = + weeks})>{}; + for (final a in activities.where((a) => !a.cancelled)) { + for (final index in a.indices) { + final weeks = a.weeks.difference( + cancellations['${a.courseCode}/$index'] ?? {}, + ); + if (weeks.isEmpty) continue; + final key = jsonEncode([ + a.courseCode, + a.name, + a.teacher, + a.room, + index, + ]); + final cell = cells.putIfAbsent( + key, + () => (a: a, index: index, weeks: {}), + ); + cell.weeks.addAll(weeks); + } + } + final groups = + < + String, + ({UndergraduateActivity a, Set weeks, List indices}) + >{}; + for (final cell in cells.values) { + final key = jsonEncode([ + cell.a.courseCode, + cell.a.name, + cell.a.teacher, + cell.a.room, + cell.index ~/ 12, + cell.weeks.toList()..sort(), + ]); + groups + .putIfAbsent( + key, + () => (a: cell.a, weeks: cell.weeks, indices: []), + ) + .indices + .add(cell.index); + } + final courses = []; + for (final group in groups.values) { + final indices = group.indices..sort(); + for (var p = 0; p < indices.length;) { + final first = indices[p]; + var last = first; + p++; + while (p < indices.length && indices[p] == last + 1) { + last = indices[p++]; + } + final a = group.a, info = roster[a.courseCode]; + final identity = jsonEncode([ + term.id, + a.courseCode, + a.name, + a.teacher, + a.room, + first, + last, + group.weeks.toList()..sort(), + ]); + courses.add( + Course( + id: 'uestc-undergraduate-${term.id}-${_hash(identity)}', + name: a.name, + teacher: a.teacher, + room: a.room, + weekday: first ~/ 12 + 1, + startPeriod: first % 12 + 1, + endPeriod: last % 12 + 1, + weeks: group.weeks, + source: source, + courseCode: info?.code ?? a.courseCode, + notes: info?.notes ?? '', + ), + ); + } + } + if (courses.isEmpty && !explicitEmpty) + throw const FormatException('没有识别到有效上课安排'); + return ImportDraft( + courses: List.unmodifiable(courses), + explicitlyEmpty: explicitEmpty, + source: source, + parserVersion: 'uestc-undergraduate-v1', + schoolTerm: term, + semesterLabel: term.label, + ); + } on FormatException { + rethrow; + } catch (_) { + throw const FormatException('本科课表结构或学期无效,原课表保持不变'); + } + } + + static String _hash(String value) { + var h = 0x811c9dc5; + for (final b in utf8.encode(value)) { + h = ((h ^ b) * 0x01000193) & 0xffffffff; + } + return h.toRadixString(16).padLeft(8, '0'); + } +} diff --git a/mobile/test/fixtures/school/undergraduate-timetable.html b/mobile/test/fixtures/school/undergraduate-timetable.html new file mode 100644 index 0000000..20a6313 --- /dev/null +++ b/mobile/test/fixtures/school/undergraduate-timetable.html @@ -0,0 +1,68 @@ +
节次星期一星期二星期三星期四星期五星期六星期日
序号课程代码课程名称课程类别学分课程序号教师职称教材信息选课备注
1C1课程1专业课3C1.01教师1上课时间:19:00-22:00
2C2课程2专业课3C2.01教师1
3C3课程3专业课3C3.01教师1
4C4课程4专业课3C4.01教师1
5C5课程5专业课3C5.01教师1
6C6课程6专业课3C6.01教师1
7C7课程7专业课3C7.01教师1
8C8课程8专业课3C8.01教师1
9C9课程9专业课3C9.01教师1
10C10课程10专业课3C10.01教师1
\ No newline at end of file diff --git a/mobile/test/school/undergraduate_timetable_adapter_test.dart b/mobile/test/school/undergraduate_timetable_adapter_test.dart new file mode 100644 index 0000000..5b68627 --- /dev/null +++ b/mobile/test/school/undergraduate_timetable_adapter_test.dart @@ -0,0 +1,144 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'package:html/parser.dart' as html; +import 'package:hepan_everyday/features/school/undergraduate/activity_data.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/school/undergraduate/timetable_adapter.dart'; +import 'package:hepan_everyday/data/schedule_file_import.dart'; +import 'package:hepan_everyday/domain/school_identity.dart'; + +void main() { + final uri = Uri.parse( + 'https://eams.uestc.edu.cn/eams/courseTableForStd.action', + ); + String fixture() => + File('test/fixtures/school/undergraduate-timetable.html') + .readAsStringSync(); + test('undergraduate activity data preserves teachers rooms weeks and period twelve', () { + final draft = UndergraduateTimetableAdapter.extract(fixture(), uri); + expect(draft.schoolTerm!.id, 523); + expect(draft.schoolTerm!.school, SupportedSchool.uestcUndergraduate); + final activities = parseActivityData( + html.parse(fixture()).querySelector('script')!.text, + unitCount: 12, + ); + final expected = {}; + for (final a in activities.where((a) => !a.cancelled)) { + for (final index in a.indices) { + final cancelled = activities + .where( + (c) => + c.cancelled && + c.courseCode == a.courseCode && + c.indices.contains(index), + ) + .expand((c) => c.weeks) + .toSet(); + for (final week in a.weeks.difference(cancelled)) { + expected.add( + jsonEncode([ + a.courseCode.split('.').first, + a.teacher, + a.room, + index, + week, + ]), + ); + } + } + } + final actual = { + for (final c in draft.courses) + for (var p = c.startPeriod; p <= c.endPeriod; p++) + for (final w in c.weeks) + jsonEncode([ + c.courseCode, + c.teacher, + c.room, + (c.weekday - 1) * 12 + p - 1, + w, + ]), + }; + expect( + actual, + expected, + reason: 'continuous grouping must preserve every original active index and week', + ); + expect(draft.courses, hasLength(18)); + expect(draft.courses.map((c) => c.courseCode).toSet(), hasLength(10)); + expect(draft.courses.where((c) => c.endPeriod == 12), hasLength(3)); + expect(draft.courses.any((c) => c.room == '停课'), isFalse); + expect(draft.courses.any((c) => c.teacher.contains('教师9')), isTrue); + expect(draft.courses.any((c) => c.notes.contains('19:00-22:00')), isTrue); + expect(ScheduleFileImport.parse(fixture()).schoolTerm, draft.schoolTerm); + expect( + UndergraduateTimetableAdapter.extract( + fixture(), + uri, + ).courses.map((c) => c.id), + draft.courses.map((c) => c.id), + ); + }); + test( + 'invalid or incomplete activity data never becomes an empty timetable', + () { + for (final html in [ + '登录', + fixture().replaceAll('unitCount = 12', 'unitCount = 11'), + fixture().replaceFirst( + RegExp(r'index = \d+\s*\*\s*unitCount\s*\+\s*\d+'), + 'index = unknown()', + ), + fixture().replaceFirst('value="523"', 'value="504"'), + fixture().replaceFirst('"教师1"', '""'), + ]) { + expect( + () => UndergraduateTimetableAdapter.extract(html, uri), + throwsFormatException, + ); + } + expect( + () => UndergraduateTimetableAdapter.extract( + fixture(), + Uri.parse('https://idas.uestc.edu.cn/'), + ), + throwsFormatException, + ); + }, + ); + test('explicit empty pages require recognized table and actual term', () { + final empty = + '
暂无课程
'; + expect( + UndergraduateTimetableAdapter.extract(empty, uri).explicitlyEmpty, + isTrue, + ); + expect( + () => UndergraduateTimetableAdapter.extract( + empty.replaceAll('暂无课程', ''), + uri, + ), + throwsFormatException, + ); + }); + final original = + Platform.environment['UESTC_UNDERGRADUATE_TIMETABLE_FIXTURE']; + if (original != null) + test( + 'actual supplied undergraduate HTML has complete normalized arrangements', + () { + final draft = UndergraduateTimetableAdapter.extract( + File(original).readAsStringSync(), + uri, + ); + expect(draft.courses, hasLength(18)); + expect(draft.courses.map((c) => c.courseCode).toSet(), hasLength(10)); + expect(draft.courses.where((c) => c.endPeriod == 12), hasLength(3)); + expect( + draft.courses.every((c) => c.teacher.isNotEmpty && c.room.isNotEmpty), + isTrue, + ); + }, + ); +}