Read submitted text and teacher feedback from the homework page
You were right that this data never reaches the browser as an API call.
The legacy front end calls the Feathers API server-side for
submission.comment, submission.grade and submission.gradeComment and
renders them into GET /homework/{taskId}. That Feathers API is not
exposed publicly — /api/v1/* 404s — so the rendered page is the only way
to reach these fields from outside.
core/homework-page.ts parses it, hooked on the data-testid attributes
the project's own e2e tests use rather than incidental markup. The page
authenticates by jwt *cookie*; an Authorization header is ignored and
redirects to the identity provider. Every field is optional and parse
failures return undefined, so a markup change degrades to "not found"
and cannot break get_task. The wording distinguishes the two: absent
feedback is reported as not found, never as none given.
Measured on one course: 4 of 7 graded submissions carry feedback no API
call can return — "vollständig und nachvollziehbar", "Feedback siehe
Zettel", and so on.
This exposed a bug in a shared utility: htmlToText decoded only six
entities, so any named entity passed through raw. German content makes
that routine — "vollständig" would have reached the model verbatim
from boards and task descriptions too, not just here. It now decodes
named, decimal and hex references in one pass, so ä stays
literal instead of decoding twice, and leaves unknown names alone rather
than mangling them.
Also fixes a documented-recovery bug found while restoring the session:
`docker compose restart` does not re-read env_file, so it silently kept
serving the dead token. `up -d` is correct and the docs said the wrong
thing.
78 tests, 38/38 smoke; verified end to end through Claude Code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
120
src/core/homework-page.ts
Normal file
120
src/core/homework-page.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Config } from '../config.ts';
|
||||
import { decodeEntities, htmlToText } from './text.ts';
|
||||
|
||||
/**
|
||||
* Reads submission detail off the legacy web UI's homework page.
|
||||
*
|
||||
* This is scraping, and it is deliberate. The v3 API's only submission route
|
||||
* returns `{id, submitters, isSubmitted, isGraded, grade,
|
||||
* submittingCourseGroupName}` — no submitted text, no written feedback. Those
|
||||
* fields exist, but only the legacy Feathers API has them, and that API is not
|
||||
* exposed publicly on this instance (`/api/v1/*` 404s). The legacy front end
|
||||
* calls it server-side and renders the result, so the rendered page is the only
|
||||
* place this data can be reached from outside.
|
||||
*
|
||||
* Measured on a real course: 4 of 7 graded submissions carried teacher feedback
|
||||
* that no API call can retrieve.
|
||||
*
|
||||
* Two things make this less fragile than scraping usually is:
|
||||
* - the hooks are `data-testid` attributes, which exist for the project's own
|
||||
* end-to-end tests and so are not incidental markup;
|
||||
* - nothing depends on it. Every field is optional, parse failures return
|
||||
* undefined, and the API-derived facts are unaffected.
|
||||
*
|
||||
* Authentication is the `jwt` **cookie**, not a bearer header: the front end
|
||||
* ignores `Authorization` and redirects to the identity provider.
|
||||
*/
|
||||
|
||||
export interface SubmissionDetail {
|
||||
/** The student's own typed answer, when they wrote one. */
|
||||
submittedText?: string;
|
||||
/** The teacher's written feedback. */
|
||||
gradeComment?: string;
|
||||
/** Percentage grade, when one was given. */
|
||||
gradePercent?: number;
|
||||
/** Files the teacher returned with the grading. */
|
||||
gradingFiles: { id: string; name: string }[];
|
||||
/** Files the student handed in — cross-check for the files-storage listing. */
|
||||
submittedFiles: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export async function fetchSubmissionDetail(
|
||||
config: Config,
|
||||
taskId: string,
|
||||
): Promise<SubmissionDetail | undefined> {
|
||||
const url = `${config.baseUrl}/homework/${encodeURIComponent(taskId)}`;
|
||||
let html: string;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' },
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
||||
redirect: 'follow',
|
||||
});
|
||||
// A redirect away from the instance means the cookie was not accepted;
|
||||
// there is nothing to parse and nothing worth reporting as an error.
|
||||
if (!response.ok || !new URL(response.url).hostname.endsWith(new URL(config.baseUrl).hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
html = await response.text();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parseHomeworkPage(html);
|
||||
}
|
||||
|
||||
/** Exported for testing: the parsing is pure and deserves fixtures, not a network. */
|
||||
export function parseHomeworkPage(html: string): SubmissionDetail | undefined {
|
||||
const submission = section(html, 'submission');
|
||||
const feedback = section(html, 'feedback');
|
||||
if (submission === undefined && feedback === undefined) return undefined;
|
||||
|
||||
const detail: SubmissionDetail = { gradingFiles: [], submittedFiles: [] };
|
||||
|
||||
// The student's own text: a textarea while the submission is still editable,
|
||||
// a plain div once it is not.
|
||||
const typed =
|
||||
/data-testid="submission-text"[^>]*>([\s\S]*?)<\/textarea>/.exec(html)?.[1] ??
|
||||
(submission ? /<div class="comment"[^>]*>([\s\S]*?)<\/div>/.exec(submission)?.[1] : undefined);
|
||||
const typedText = clean(typed);
|
||||
if (typedText) detail.submittedText = typedText;
|
||||
|
||||
const comment = clean(/data-testid="feedback-comment"[^>]*>([\s\S]*?)<\/div>/.exec(html)?.[1]);
|
||||
if (comment) detail.gradeComment = comment;
|
||||
|
||||
// Rendered by the template as "you have solved X%" in the feedback tab only.
|
||||
if (feedback) {
|
||||
const percent = /(\d{1,3})\s*%/.exec(stripTags(feedback));
|
||||
if (percent) detail.gradePercent = Number(percent[1]);
|
||||
}
|
||||
|
||||
detail.submittedFiles = fileCards(submission);
|
||||
detail.gradingFiles = fileCards(feedback);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** The tab bodies are server-rendered `<section id="…">` blocks. */
|
||||
function section(html: string, id: string): string | undefined {
|
||||
return new RegExp(`<section id="${id}"[^>]*>([\\s\\S]*?)</section>`).exec(html)?.[1];
|
||||
}
|
||||
|
||||
/** File cards carry their id and name as data attributes. */
|
||||
function fileCards(markup: string | undefined): { id: string; name: string }[] {
|
||||
if (!markup) return [];
|
||||
const found: { id: string; name: string }[] = [];
|
||||
const pattern = /data-file-name="([^"]*)"[^>]*data-file-size="[^"]*"[^>]*data-file-id="([0-9a-f]{24})"/g;
|
||||
for (const match of markup.matchAll(pattern)) {
|
||||
found.push({ name: decodeEntities(match[1] ?? ''), id: match[2] ?? '' });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function clean(markup: string | undefined): string | undefined {
|
||||
if (!markup) return undefined;
|
||||
const text = htmlToText(markup).trim();
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
function stripTags(markup: string): string {
|
||||
return markup.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
|
||||
}
|
||||
@@ -7,26 +7,67 @@
|
||||
* Mongo ids — is dropped.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Named HTML entities worth decoding.
|
||||
*
|
||||
* Not the full HTML5 table — that is ~2000 entries for a handful this content
|
||||
* actually uses. German umlauts are the ones that matter: the legacy web pages
|
||||
* emit `ä` rather than UTF-8, so without these a grade comment comes back
|
||||
* as "vollständig".
|
||||
*/
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
|
||||
auml: 'ä', ouml: 'ö', uuml: 'ü', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü', szlig: 'ß',
|
||||
aacute: 'á', agrave: 'à', acirc: 'â', eacute: 'é', egrave: 'è', ecirc: 'ê',
|
||||
iacute: 'í', igrave: 'ì', oacute: 'ó', ograve: 'ò', ocirc: 'ô',
|
||||
uacute: 'ú', ugrave: 'ù', ccedil: 'ç', ntilde: 'ñ',
|
||||
euro: '€', pound: '£', deg: '°', middot: '·', bull: '•', hellip: '…',
|
||||
ndash: '–', mdash: '—', laquo: '«', raquo: '»',
|
||||
lsquo: '\u2018', rsquo: '\u2019', ldquo: '\u201c', rdquo: '\u201d', bdquo: '\u201e',
|
||||
times: '×', divide: '÷', plusmn: '±', copy: '©', reg: '®', trade: '™', shy: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes HTML entities: named, decimal and hexadecimal.
|
||||
*
|
||||
* `&` is handled by the same pass as everything else rather than last,
|
||||
* so `&auml;` stays the literal text `ä` instead of being decoded
|
||||
* twice into `ä`.
|
||||
*/
|
||||
export function decodeEntities(value: string): string {
|
||||
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, body: string) => {
|
||||
if (body.startsWith('#')) {
|
||||
const code = body[1] === 'x' || body[1] === 'X'
|
||||
? Number.parseInt(body.slice(2), 16)
|
||||
: Number.parseInt(body.slice(1), 10);
|
||||
if (!Number.isFinite(code) || code <= 0 || code > 0x10ffff) return whole;
|
||||
try {
|
||||
return String.fromCodePoint(code);
|
||||
} catch {
|
||||
return whole;
|
||||
}
|
||||
}
|
||||
// Unknown names are left alone: mangling them loses information.
|
||||
return NAMED_ENTITIES[body] ?? whole;
|
||||
});
|
||||
}
|
||||
|
||||
/** Collapses Schulcloud's CKEditor HTML into plain text, keeping link targets. */
|
||||
export function htmlToText(html: string | undefined | null): string {
|
||||
if (!html) return '';
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '- ')
|
||||
// Keep the href when the anchor text does not already contain it.
|
||||
.replace(/<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_, href: string, label: string) => {
|
||||
const text = label.replace(/<[^>]+>/g, '').trim();
|
||||
if (!text) return href;
|
||||
return text === href ? href : `${text} (${href})`;
|
||||
})
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
return decodeEntities(
|
||||
html
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '- ')
|
||||
// Keep the href when the anchor text does not already contain it.
|
||||
.replace(/<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_, href: string, label: string) => {
|
||||
const text = label.replace(/<[^>]+>/g, '').trim();
|
||||
if (!text) return href;
|
||||
return text === href ? href : `${text} (${href})`;
|
||||
})
|
||||
.replace(/<[^>]+>/g, ''),
|
||||
)
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
Reference in New Issue
Block a user