getAcademicPerformance method
Fetches academic performance (scores) for all semesters.
Scans the academic performance page for semester buttons and their corresponding score tables.
Parsing Strategy:
Uses a sequential scan of both input (semester labels) and table
(score data) elements to maintain correct association. This prevents
offsets in cases where a semester button exists but its score table is
missing (e.g., early in a new term).
Throws an Exception if the response contains "應用系統已中斷連線", indicating session expiry.
Implementation
Future<List<SemesterScoreDto>> getAcademicPerformance() async {
final response = await _studentQueryDio.get(
'QryScore.jsp',
queryParameters: {'format': '-2'},
);
// Safeguard: Detect session termination to trigger auto-reauthentication
if (response.data.toString().contains('應用系統已中斷連線')) {
throw Exception('SessionExpired');
}
final document = parse(response.data);
// Matches semester labels e.g., "114 學年度 第 1 學期"
final semesterPattern = RegExp(r'(\d+)\s*學年度\s*第\s*(\d+)\s*學期');
// Query both inputs and tables to preserve document order
final elements = document.querySelectorAll("input[type='submit'], table");
final results = <SemesterScoreDto>[];
SemesterDto? currentSemester;
bool hasAddedTableForCurrentSemester = false;
for (final el in elements) {
if (el.localName == 'input') {
// Found a semester switcher: update current context
final value = el.attributes['value'] ?? '';
final match = semesterPattern.firstMatch(value);
if (match != null) {
currentSemester = (
year: int.parse(match.group(1)!),
term: int.parse(match.group(2)!),
);
hasAddedTableForCurrentSemester = false;
}
} else if (el.localName == 'table') {
// Found a data table: verify context and prevent duplicate processing
if (currentSemester == null || hasAddedTableForCurrentSemester) {
continue;
}
final rows = el.querySelectorAll('tr');
final scores = <ScoreDto>[];
double? average;
double? conduct;
double? totalCredits;
double? creditsPassed;
String? note;
bool isDataParsed = false;
// Row 0 is the header; data rows have 9+ columns, summary rows have 2
for (final row in rows.skip(1)) {
final cells = row.querySelectorAll('th, td');
if (cells.length >= 9) {
isDataParsed = true;
final scoreText = _parseCellText(cells[7]);
final (scoreValue, status) = _parseScore(scoreText);
scores.add((
number: _parseCellText(cells[0]),
courseCode: _parseCellText(cells[4]),
score: scoreValue,
status: status,
));
} else if (cells.length == 2) {
final label = cells[0].text;
final value = _parseCellText(cells[1]);
if (label.contains('Average')) {
isDataParsed = true;
average = double.tryParse(value ?? '');
} else if (label.contains('Conduct')) {
isDataParsed = true;
conduct = double.tryParse(value ?? '');
} else if (label.contains('Total Credits')) {
isDataParsed = true;
totalCredits = double.tryParse(value ?? '');
} else if (label.contains('Credits Passed')) {
isDataParsed = true;
creditsPassed = double.tryParse(value ?? '');
} else if (label.contains('Note')) {
isDataParsed = true;
note = value;
}
}
}
if (isDataParsed) {
results.add((
semester: currentSemester,
scores: scores,
average: average,
conduct: conduct,
totalCredits: totalCredits,
creditsPassed: creditsPassed,
note: note,
));
hasAddedTableForCurrentSemester = true;
}
}
}
return results;
}