Files
Schulcloud-MCP/src/core/h5p.ts
MechaCat02 a0cef532c6 Read the quizzes behind H5P elements
A quiz in Schulcloud is an H5P element, and a board hands over nothing but a
contentId — so a teacher's exercise was until now a line saying one exists.
The player shows a single question at a time, which makes it look like
something to step through or scrape. It is not:
`GET /api/v3/h5p-editor/params/{contentId}` returns the JSON the player is fed,
so one request holds every question, every option and which of them are
correct. (`play/{id}` is the same content plus the player's script lists: 74 kB
against 51 kB for the live quiz. Neither docs-json describes the service.)

get_h5p prints the exercise, and solutions=false keeps the options while
dropping the answers, so it can be used to ask the questions instead of
answering them. get_board names the exercise — title, question count, kinds —
rather than printing a bare id, and the crawl indexes its text, so a phrase
that exists only inside a quiz is now findable. That is the treatment pads
already get, for the same reason: it is course material and nothing else
surfaces it.

What varies is the shape inside `params`, which belongs to whichever H5P
library the teacher used. Modelled: MultiChoice, whose `behaviour.singleAnswer`
is the only honest source for "tick exactly one"; TrueFalse, whose `correct` is
the string "true"; the cloze libraries, which mark solutions inline as
`*answer:tip*`; SingleChoiceSet and Summary, which put the correct option first
and let the player shuffle; and Column. Anything else has its text harvested
and labelled unmodelled — an exercise reported as "0 questions" would be worse
than a clumsy rendering of one. The harvest skips the UI and l10n subtrees, or
a quiz reads as "Überprüfen, Wiederholen, Absenden".

Verified against this account's quiz, an H5P.QuestionSet of 20 MultiChoice
questions on a room's board: 239 tests, smoke 91/91 live-only and 93/93 with
the index.

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

306 lines
11 KiB
TypeScript

/**
* Reads H5P content: the quizzes and interactive exercises on a board.
*
* Schulcloud has no quiz of its own — an exercise is an H5P element, and the
* board API hands over nothing but a `contentId`. The player then shows one
* question at a time, which makes a quiz look like something that has to be
* stepped through or scraped. It is not: `GET /api/v3/h5p-editor/params/{id}`
* returns the entire exercise as the JSON the player is fed, so one request
* holds every question, every option and which of them is correct.
*
* What varies is the *shape* of that JSON, because it belongs to whichever H5P
* library the teacher used. The common question types are modelled below; for
* anything else the text is harvested generically and labelled as such, so an
* exercise this parser does not know still arrives readable instead of empty.
*/
import type { SchulcloudClient } from './client.ts';
import { htmlToText } from './text.ts';
export interface H5pAnswer {
text: string;
/** Undefined when the library does not say — never guess a solution. */
correct?: boolean;
tip?: string;
}
export interface H5pQuestion {
/** The H5P library, e.g. `H5P.MultiChoice 1.16`. */
library: string;
/** A short human name for the kind of task, for the reader. */
kind: string;
text: string;
answers: H5pAnswer[];
/** True when more than one option is meant to be ticked. */
multiple?: boolean;
/** The task's own text where it is not the question: a cloze, a description. */
body?: string;
}
export interface H5pContent {
contentId: string;
title: string;
/** The main library of the content itself, e.g. `H5P.QuestionSet`. */
library: string;
intro?: string;
/** Percentage needed to pass, when the content sets one. */
passPercentage?: number;
questions: H5pQuestion[];
/**
* Text from parts this parser does not model. Never silently dropped: a
* teacher's exercise turning up as "0 questions" is the bug this avoids.
*/
unmodelled: string[];
}
/** Fetches and parses one H5P content. Throws if it cannot be read or parsed. */
export async function readH5pContent(client: SchulcloudClient, contentId: string): Promise<H5pContent> {
return parseH5pParams(contentId, await client.getH5pParams(contentId));
}
/** Everything in the content as one string, for the search index. */
export function h5pSearchText(content: H5pContent): string {
const parts = [content.title, content.intro];
for (const question of content.questions) {
parts.push(question.text, question.body, ...question.answers.map((answer) => answer.text));
}
parts.push(...content.unmodelled);
return parts.filter((part): part is string => Boolean(part?.trim())).join('\n');
}
/**
* Interprets a `params` payload.
*
* Throws on a payload that is not H5P at all rather than returning an empty
* exercise — the difference between "this quiz has no questions" and "the
* format changed" matters, and only one of them is worth reporting as content.
*/
export function parseH5pParams(contentId: string, payload: unknown): H5pContent {
const root = asRecord(payload);
const metadata = asRecord(root?.h5p);
// `params.params` is the content itself; `params.metadata` beside it repeats
// the H5P metadata. A single-question content has the same shape.
const outer = asRecord(root?.params);
const params = asRecord(outer?.params) ?? outer;
const library = string(metadata?.mainLibrary) ?? string(root?.library) ?? 'unknown';
if (!params) {
throw new Error(`H5P content ${contentId} carried no params — the payload shape has changed`);
}
const title = string(metadata?.title)?.trim() || 'H5P content';
const content: H5pContent = { contentId, title, library, questions: [], unmodelled: [] };
const introPage = asRecord(params.introPage);
const intro = htmlToText(string(introPage?.introduction) ?? string(params.intro) ?? '').trim();
if (intro) content.intro = intro;
if (typeof params.passPercentage === 'number') content.passPercentage = params.passPercentage;
// A QuestionSet holds a list of sub-contents, each with its own library; any
// other library *is* the single question.
const questions = Array.isArray(params.questions) && params.questions.some((entry) => asRecord(entry)?.library)
? params.questions
: undefined;
if (questions) {
for (const entry of questions) {
const record = asRecord(entry);
const sub = asRecord(record?.params);
const subLibrary = string(record?.library) ?? 'unknown';
if (sub) content.questions.push(...parseQuestion(subLibrary, sub, content.unmodelled));
}
} else {
content.questions.push(...parseQuestion(library, params, content.unmodelled));
}
return content;
}
/**
* One sub-content as questions.
*
* Returns several for the libraries that bundle them (SingleChoiceSet,
* Summary, Blanks) and none for one it cannot read, in which case its text goes
* to `unmodelled`.
*/
function parseQuestion(library: string, params: Record<string, unknown>, unmodelled: string[]): H5pQuestion[] {
const name = library.split(/\s+/)[0] ?? library;
const question = (text: string, answers: H5pAnswer[], extra: Partial<H5pQuestion> = {}): H5pQuestion => ({
library,
kind: KIND[name] ?? name.replace(/^H5P\./, ''),
text,
answers,
...extra,
});
switch (name) {
case 'H5P.MultiChoice': {
const answers = (asArray(params.answers) ?? []).map((entry): H5pAnswer => {
const answer = asRecord(entry);
const tip = htmlToText(string(asRecord(answer?.tipsAndFeedback)?.tip) ?? '').trim();
return {
text: htmlToText(string(answer?.text) ?? '').trim(),
correct: answer?.correct === true,
...(tip ? { tip } : {}),
};
});
// `singleAnswer` is what the player uses to choose radio buttons over
// checkboxes, and it is the only honest way to say "tick one".
const single = asRecord(params.behaviour)?.singleAnswer === true;
return [
question(htmlToText(string(params.question) ?? '').trim(), answers, {
multiple: !single && answers.filter((answer) => answer.correct).length !== 1,
}),
];
}
case 'H5P.TrueFalse': {
const l10n = asRecord(params.l10n);
const yes = string(l10n?.trueText) ?? 'Wahr';
const no = string(l10n?.falseText) ?? 'Falsch';
// `correct` is the string "true" or "false", not a boolean.
const correct = string(params.correct);
return [
question(htmlToText(string(params.question) ?? '').trim(), [
{ text: yes, correct: correct === 'true' },
{ text: no, correct: correct === 'false' },
]),
];
}
case 'H5P.Blanks': {
const description = htmlToText(string(params.text) ?? '').trim();
return (asArray(params.questions) ?? []).flatMap((entry) => {
const raw = string(entry);
if (!raw) return [];
const { text, answers } = parseCloze(raw);
return [question(text, answers, { body: description || undefined })];
});
}
case 'H5P.DragText':
case 'H5P.MarkTheWords': {
const raw = string(params.textField) ?? '';
const { text, answers } = parseCloze(raw);
const description = htmlToText(string(params.taskDescription) ?? '').trim();
return [question(description || 'Aufgabe', answers, { body: text })];
}
case 'H5P.SingleChoiceSet': {
return (asArray(params.choices) ?? []).flatMap((entry) => {
const choice = asRecord(entry);
const texts = (asArray(choice?.answers) ?? []).map((answer) => htmlToText(string(answer) ?? '').trim());
// The first option is the correct one; the player shuffles them.
const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 }));
return [question(htmlToText(string(choice?.question) ?? '').trim(), answers)];
});
}
case 'H5P.Summary': {
return (asArray(params.summaries) ?? []).flatMap((entry) => {
const group = asRecord(entry);
const texts = (asArray(group?.summary) ?? []).map((item) => htmlToText(string(item) ?? '').trim());
const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 }));
return [question(htmlToText(string(params.intro) ?? '').trim() || 'Welche Aussage stimmt?', answers)];
});
}
case 'H5P.Column': {
// A column stacks sub-contents; each carries its own library.
return (asArray(params.content) ?? []).flatMap((entry) => {
const inner = asRecord(asRecord(entry)?.content);
const innerParams = asRecord(inner?.params);
const innerLibrary = string(inner?.library) ?? 'unknown';
return innerParams ? parseQuestion(innerLibrary, innerParams, unmodelled) : [];
});
}
default: {
const harvested = harvest(params);
if (harvested.length > 0) unmodelled.push(`${name}: ${harvested.join(' | ')}`);
return [];
}
}
}
/** Short names for the libraries worth naming; anything else keeps its own. */
const KIND: Record<string, string> = {
'H5P.MultiChoice': 'multiple choice',
'H5P.TrueFalse': 'true/false',
'H5P.Blanks': 'fill in the blanks',
'H5P.DragText': 'drag the words',
'H5P.MarkTheWords': 'mark the words',
'H5P.SingleChoiceSet': 'single choice',
'H5P.Summary': 'pick the correct statement',
};
/**
* A cloze text: H5P marks the solutions inline as `*answer:tip*`, with
* alternatives separated by slashes.
*/
export function parseCloze(raw: string): { text: string; answers: H5pAnswer[] } {
const answers: H5pAnswer[] = [];
const text = htmlToText(
raw.replace(/\*([^*]+)\*/g, (_, body: string) => {
const [solutions, tip] = body.split(':');
const alternatives = (solutions ?? '').split('/').map((part) => part.trim()).filter(Boolean);
answers.push({
text: alternatives.join(' / '),
correct: true,
...(tip?.trim() ? { tip: tip.trim() } : {}),
});
return `____ (${answers.length})`;
}),
).trim();
return { text, answers };
}
/**
* Every bit of task text in an unmodelled library.
*
* Deliberately blunt — it cannot know which field is the question — but it
* skips the subtrees that hold button labels and display settings, which
* otherwise drown the content in "Überprüfen" and "Wiederholen".
*/
const SKIP_KEYS = new Set([
'UI',
'l10n',
'behaviour',
'overallFeedback',
'confirmCheck',
'confirmRetry',
'media',
'localization',
'a11y',
'accessibility',
'scoreBarLabel',
'texts',
'endGame',
'override',
]);
function harvest(value: unknown, depth = 0, out: string[] = []): string[] {
if (depth > 6 || out.length > 40) return out;
if (typeof value === 'string') {
const text = htmlToText(value).trim();
// Two characters of prose, not a colour code or a library version.
if (text.length > 2 && /\p{L}{2}/u.test(text) && !out.includes(text)) out.push(text);
return out;
}
if (Array.isArray(value)) {
for (const item of value) harvest(item, depth + 1, out);
return out;
}
const record = asRecord(value);
if (record) {
for (const [key, item] of Object.entries(record)) {
if (SKIP_KEYS.has(key)) continue;
harvest(item, depth + 1, out);
}
}
return out;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function asArray(value: unknown): unknown[] | undefined {
return Array.isArray(value) ? value : undefined;
}
function string(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}