199 lines
6.7 KiB
Dart
199 lines
6.7 KiB
Dart
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)
|
|
: <UndergraduateActivity>[];
|
|
final explicitEmpty =
|
|
activities.isEmpty && RegExp(r'暂无课程|无课程安排|没有课程').hasMatch(table.text);
|
|
if (activities.isEmpty && !explicitEmpty) {
|
|
throw const FormatException('课表尚未加载完整');
|
|
}
|
|
final roster = <String, ({String code, String notes})>{};
|
|
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 = <String, Set<int>>{};
|
|
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 =
|
|
<String, ({UndergraduateActivity a, int index, Set<int> 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: <int>{}),
|
|
);
|
|
cell.weeks.addAll(weeks);
|
|
}
|
|
}
|
|
final groups =
|
|
<
|
|
String,
|
|
({UndergraduateActivity a, Set<int> weeks, List<int> 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: <int>[]),
|
|
)
|
|
.indices
|
|
.add(cell.index);
|
|
}
|
|
final courses = <Course>[];
|
|
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: [
|
|
if (info?.notes.isNotEmpty == true) info!.notes,
|
|
if (a.room == '教室待定') '学校未提供教室,请以学校最新安排为准并在公布后刷新课表。',
|
|
].join('\n'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
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');
|
|
}
|
|
}
|