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>
This commit is contained in:
305
src/core/h5p.ts
Normal file
305
src/core/h5p.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user