Files
Schulcloud-MCP/src/core/homework-page.ts
MechaCat02 5ae2210459 Close the gaps an audit of courses, tasks, files and grades turned up
Every area — courses, rooms, boards, topics, tasks, files, quizzes, teams,
groups, submissions, grades — was checked for data the instance has and the
tools did not show.

Grades and feedback. A teacher's /homework page is a different page from a
student's: grade and comment live in the grading form, one block per
submission, so a teacher account reported every graded submission as having
neither. parseTeacherGrading reads the form, and list_submissions can now
include the written feedback and who handed the work in.

Names. /api/v1 is partly served: courses, users and classes survive in the
deployment's ingress table, and users/{id} is the only route from an id to a
name. Submitters, file creators and course teachers resolve through it, and
degrade to "not visible to this account" where a student may not read them.

Courses, rooms and classes. get_course adds the description, teachers,
member count and weekly timetable from /api/v1/courses. list_classes is new.
get_room reports what the account may do — allowedOperations is an object of
booleans, not the list it was typed as — and applicants and invitation links
where it may manage them.

Board and topic content. Link descriptions, image alt text, drawing and
video-conference titles, the ids behind external tools and H5P content (the
only thing resembling a quiz), and what a deleted element used to be. Topic
Etherpad pads are read like board pads, and htmlToText keeps table columns
apart and drops template indentation.

Files. A scan with no text layer falls back to the preview endpoint, whose
width and outputFormat are undocumented enums, so Claude gets a picture of
the page; list_files reports counts and sizes. Teams stay documented as
unreadable at any API version; their files come later.

What the crawl missed. Tasks attached to topics (18 of 60 on the live
account), each course's own file area, and — behind INDEX_PERSONAL_FILES —
personal files and submissions with their grade comments, so search and
what_changed cover grading. A submission hit points at get_task.

The local instance's preview profile gets an ImageMagick policy that allows
the coders its 7.1.2 build needs; the image's own denies them all.

110 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00

207 lines
8.4 KiB
TypeScript

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 }[];
}
/**
* One submission as the *teacher's* grading form holds it.
*
* The teacher view of `/homework/{id}` is a different page from the student's:
* its tabs are `extended` and `submissions` rather than `submission` and
* `feedback`, and the grade lives in the editable form rather than in rendered
* prose. The student parser therefore finds nothing on it, which is why a
* teacher account reported every graded submission as "neither a percentage nor
* feedback was found" while the data was plainly there.
*/
export interface SubmissionGrading {
submissionId: string;
/** Ids from the form's `teamMembers` field — who handed this in. */
submitterIds: string[];
gradeComment?: string;
gradePercent?: number;
}
/** Everything one homework page yields, for whichever role is looking at it. */
export interface HomeworkPage {
/** The account's own submission, when the page is the student view. */
own?: SubmissionDetail;
/** Every submission on the grading form, when the page is the teacher view. */
grading: SubmissionGrading[];
}
export async function fetchHomeworkPage(config: Config, taskId: string): Promise<HomeworkPage | undefined> {
const html = await fetchHomeworkHtml(config, taskId);
if (html === undefined) return undefined;
const own = parseHomeworkPage(html);
const grading = parseTeacherGrading(html);
if (!own && grading.length === 0) return undefined;
return { own, grading };
}
export async function fetchSubmissionDetail(
config: Config,
taskId: string,
): Promise<SubmissionDetail | undefined> {
const html = await fetchHomeworkHtml(config, taskId);
return html === undefined ? undefined : parseHomeworkPage(html);
}
async function fetchHomeworkHtml(config: Config, taskId: string): Promise<string | undefined> {
const url = `${config.baseUrl}/homework/${encodeURIComponent(taskId)}`;
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;
}
return await response.text();
} catch {
return undefined;
}
}
/**
* Exported for testing: reads the teacher's grading form.
*
* Anchored on the form's own `name=` attributes rather than on layout, because
* those are what the POST handler reads and so cannot drift without the feature
* itself changing. Each submission contributes one `submissionId` hidden input,
* a `teamMembers` input naming who handed it in, a `grade` number input, and a
* `gradeComment` textarea whose body is HTML-escaped twice over.
*/
export function parseTeacherGrading(html: string): SubmissionGrading[] {
const found: SubmissionGrading[] = [];
const blocks = html.split(/<input name="submissionId"/);
for (const block of blocks.slice(1)) {
const submissionId = /value="([0-9a-f]{24})"/.exec(block)?.[1];
if (!submissionId) continue;
// Only trust fields belonging to this submission: the next block starts
// at the following submissionId input, so cut there first.
const members = /<input name="teamMembers"[^>]*value="([^"]*)"/.exec(block)?.[1] ?? '';
const submitterIds = members
.split(',')
.map((id) => id.trim())
.filter((id) => /^[0-9a-f]{24}$/.test(id));
const entry: SubmissionGrading = { submissionId, submitterIds };
// `value=""` means ungraded; the placeholder is a hint, not a grade.
const gradeValue = /name="grade"[^>]*?value="(\d{1,3})"/.exec(block)?.[1];
if (gradeValue !== undefined) entry.gradePercent = Number(gradeValue);
const commentMarkup = new RegExp(
`<textarea[^>]*data-parent-id="${submissionId}"[^>]*>([\\s\\S]*?)</textarea>`,
).exec(html)?.[1];
const comment = clean(commentMarkup ? decodeEntities(commentMarkup) : undefined);
if (comment) entry.gradeComment = comment;
found.push(entry);
}
return found;
}
/** 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.
//
// The read-only div is a *sibling after* `</section id="submission">`, not a
// child of it — that section then holds only the file list. Scoping this
// search to the section therefore found nothing for every submission past
// its due date, silently dropping the submitted text while still reporting
// the grade. `class="comment"` (with the quote right after the word) is
// specific enough to search the whole page: the teacher's feedback is
// `class="comment ckcontent"` and so cannot match.
const typed =
/data-testid="submission-text"[^>]*>([\s\S]*?)<\/textarea>/.exec(html)?.[1] ??
/<div class="comment"[^>]*>([\s\S]*?)<\/div>/.exec(html)?.[1];
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, ' ');
}