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>
207 lines
8.4 KiB
TypeScript
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, ' ');
|
|
}
|