// ==UserScript==
// @name Noordhoff answers
// @version 1.2
// @description Intercepts exercise data and renders answers, including MathML formulas.
// @match *://apps.noordhoff.nl/*
// @grant none
// @run-at document-start
// @namespace https://greasyfork.org/users/1511158
// ==/UserScript==
(() => {
'use strict';
// config
const TARGET_OPERATION = "ContentUnitForPlayableContent";
const DEFAULT_PANEL_WIDTH = 520;
const COLLAPSE_HANDLE_WIDTH = 10; // How many pixels are visible when collapsed for hover
const MAX_RAW_PREVIEW = 20000;
// --- MathJax Integration ---
let mathJaxLoaded = false;
function loadMathJax() {
if (mathJaxLoaded || window.MathJax) {
mathJaxLoaded = true;
return;
}
mathJaxLoaded = true; // Prevent multiple loads
console.info('CUFPC-latest: Loading MathJax to render formulas.');
window.MathJax = {
loader: {load: ['input/mml', 'output/chtml']},
startup: {
ready: () => {
console.info('CUFPC-latest: MathJax is ready.');
window.MathJax.startup.defaultReady();
}
}
};
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js';
script.async = true;
document.head.appendChild(script);
}
// panel singleton
let panel;
// Panel state functions, moved to outer scope for hotkey access
function collapsePanel() {
if (!panel) return;
panel.classList.add('cufpc-collapsed');
const toggle = panel.querySelector('#cufpc-toggle');
if (toggle) toggle.textContent = '▸';
}
function expandPanel() {
if (!panel) return;
panel.classList.remove('cufpc-collapsed');
const toggle = panel.querySelector('#cufpc-toggle');
if (toggle) toggle.textContent = '▾';
}
function togglePanel() {
if (!panel) return;
if (panel.classList.contains('cufpc-collapsed')) expandPanel();
else collapsePanel();
}
function ensurePanel() {
if (panel) return panel;
loadMathJax();
panel = document.createElement('div');
panel.id = 'cufpc-panel-latest';
panel.innerHTML = `
<div id="cufpc-resizer"></div>
<div id="cufpc-topbar">
<div id="cufpc-title">ContentUnitForPlayableContent — latest</div>
<div id="cufpc-controls">
<button id="cufpc-toggle" title="collapse panel">▾</button>
<button id="cufpc-copy" title="copy latest">Copy</button>
<button id="cufpc-download" title="download latest">⬇</button>
<button id="cufpc-raw-toggle" title="toggle raw">Raw</button>
</div>
</div>
<div id="cufpc-body">
<div id="cufpc-meta"></div>
<div id="cufpc-answers"></div>
<div id="cufpc-debug" style="display:none"></div>
<pre id="cufpc-raw" style="display:none"></pre>
</div>
`;
// Set width from storage or use default
const savedWidth = localStorage.getItem('cufpc-panel-width');
panel.style.width = savedWidth ? savedWidth : `${DEFAULT_PANEL_WIDTH}px`;
const style = document.createElement('style');
style.textContent = `
#cufpc-panel-latest {
position: fixed;
right: 0;
top: 0;
bottom: 0;
/* width is set by JS */
background: #0e1012;
color: #ddd;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial;
border-left: 1px solid rgba(255,255,255,0.04);
z-index: 2147483647;
box-shadow: -6px 0 26px rgba(0,0,0,0.6);
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 13px;
transition: transform 200ms ease;
transform: translateX(0);
}
#cufpc-panel-latest.cufpc-collapsed {
transform: translateX(calc(100% - ${COLLAPSE_HANDLE_WIDTH}px));
}
#cufpc-panel-latest.cufpc-collapsed:hover {
transform: translateX(0);
}
#cufpc-panel-latest.cufpc-resizing {
transition: none !important;
}
#cufpc-resizer {
position: absolute;
left: -2px;
top: 0;
bottom: 0;
width: 5px;
cursor: ew-resize;
z-index: 10;
}
#cufpc-topbar { display:flex; justify-content:space-between; align-items:center; padding:6px 8px; border-bottom:1px solid rgba(255,255,255,0.03); cursor:default; user-select:none; }
#cufpc-controls button { background:transparent; border:1px solid rgba(255,255,255,0.03); color:#ddd; padding:4px 6px; margin-left:6px; border-radius:6px; cursor:pointer; }
#cufpc-body { padding:8px; overflow:auto; flex:1 1 auto; transition: opacity 120ms ease; }
#cufpc-meta { font-size:12px; opacity:0.85; margin-bottom:8px; display:flex; gap:8px; flex-wrap:wrap; }
.cufpc-answer-card { border:1px solid rgba(255,255,255,0.03); padding:8px; border-radius:8px; margin-bottom:8px; background:linear-gradient(180deg, rgba(255,255,255,0.01), transparent); font-size:14px; line-height:1.6; }
.cufpc-empty { color:#f39c12; font-size:13px; padding:10px; }
.cufpc-debug { margin-top:8px; background:rgba(255,255,255,0.02); padding:8px; border-radius:6px; font-family:monospace; font-size:12px; white-space:pre-wrap; max-height:320px; overflow:auto; }
#cufpc-raw { margin-top:8px; background:rgba(255,255,255,0.02); padding:8px; border-radius:6px; max-height:320px; overflow:auto; white-space:pre-wrap; font-family:monospace; font-size:12px; }
.cufpc-key { color:#9cdcfe; font-family:monospace; }
.cufpc-string { color:#b5f4b5; }
.cufpc-null { color:#999; }
.cufpc-answer-content { white-space: pre-wrap; font-family: monospace; font-size: 13px; }
.cufpc-answer-raw { margin-top:6px; font-size:12px; opacity:0.9; color:#bdbdbd; border-top:1px dashed rgba(255,255,255,0.03); padding-top:6px; }
#cufpc-answers math { display: inline-block; vertical-align: middle; }
.cufpc-equation {
background: rgba(255,255,255,0.05);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
border: 1px solid rgba(255,255,255,0.1);
margin: 0 2px;
display: inline-block;
}
`;
document.documentElement.appendChild(style);
document.documentElement.appendChild(panel);
// --- Resizing Logic ---
const resizer = panel.querySelector('#cufpc-resizer');
resizer.addEventListener('mousedown', e => {
e.preventDefault();
panel.classList.add('cufpc-resizing');
const startX = e.clientX;
const startWidth = panel.offsetWidth;
const doDrag = (moveEvent) => {
const newWidth = startWidth - (moveEvent.clientX - startX);
if (newWidth > 200) { // Min width
panel.style.width = newWidth + 'px';
}
};
const stopDrag = () => {
panel.classList.remove('cufpc-resizing');
window.removeEventListener('mousemove', doDrag);
window.removeEventListener('mouseup', stopDrag);
localStorage.setItem('cufpc-panel-width', panel.style.width);
};
window.addEventListener('mousemove', doDrag);
window.addEventListener('mouseup', stopDrag);
});
panel.querySelector('#cufpc-toggle').addEventListener('click', (e) => { e.stopPropagation(); togglePanel(); });
panel.querySelector('#cufpc-copy').addEventListener('click', (e) => {
e.stopPropagation();
const raw = panel.dataset.latestRaw || '';
if (!raw) return alert('No latest capture yet');
navigator.clipboard?.writeText(raw).catch(()=>alert('Copying failed'));
});
panel.querySelector('#cufpc-download').addEventListener('click', (e) => {
e.stopPropagation();
const raw = panel.dataset.latestRaw || '';
if (!raw) return alert('No latest capture yet');
const blob = new Blob([raw], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ContentUnitForPlayableContent-latest-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
});
panel.querySelector('#cufpc-raw-toggle').addEventListener('click', (e) => {
e.stopPropagation();
const rawEl = panel.querySelector('#cufpc-raw');
rawEl.style.display = rawEl.style.display === 'none' ? 'block' : 'none';
});
panel.querySelector('#cufpc-topbar').addEventListener('click', (e) => {
if (e.target && e.target.closest && e.target.closest('#cufpc-controls')) return;
togglePanel();
});
const answersEl = panel.querySelector('#cufpc-answers');
const empty = document.createElement('div');
empty.className = 'cufpc-empty';
empty.textContent = 'No captures yet — waiting for responses containing data.contentUnit or operationName.';
answersEl.appendChild(empty);
return panel;
}
// Hotkey to toggle panel collapsed state
window.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'a') {
e.preventDefault();
togglePanel();
}
});
function decodeHtmlEntities(s) {
if (s === null || s === undefined) return '';
try {
const d = document.createElement('div');
d.innerHTML = s;
return d.textContent || d.innerText || '';
} catch (e) { return String(s); }
}
function tryParsePossiblyEscapedJson(s) {
if (!s || typeof s !== 'string') return null;
let candidate = s;
for (let i = 0; i < 4; i++) {
const decoded = decodeHtmlEntities(candidate);
const unescaped = decoded.replace(/\\+"/g, '"').replace(/\\+'/g, "'");
candidate = unescaped.trim();
if (candidate.startsWith('{') || candidate.startsWith('[')) {
try { return JSON.parse(candidate); } catch (e) { /* keep trying */ }
}
if (!candidate.includes('<') && !candidate.includes('&')) break;
}
return null;
}
function extractAnswersFromJson(j) {
const results = [];
try {
const cu = j?.data?.contentUnit;
const sets = cu && Array.isArray(cu.contentSets) ? cu.contentSets : (cu?.contentSets ? [cu.contentSets] : []);
for (const s of sets) {
const contentArr = Array.isArray(s?.content) ? s.content : (s?.content ? [s.content] : []);
for (const c of contentArr) {
const portable = c?.playerPayload?.portableTextContent;
if (portable?.answer !== undefined) {
if (Array.isArray(portable.answer)) results.push(...portable.answer);
else results.push(portable.answer);
}
}
}
return { answers: results };
} catch (e) {
return { answers: [], debug: { error: String(e) } };
}
}
// Renders portable text block (custom for Noordhoff)
function renderPortableText(block) {
if (!block || !block._type || !Array.isArray(block.children)) return null;
const container = document.createElement(block._type === 'p' ? 'p' : 'div');
container.style.margin = '0';
block.children.forEach(child => {
if (child._type === 'span' && typeof child.text === 'string') {
const textParts = child.text.split(/(\n)/);
textParts.forEach(part => {
if (part === '\n') {
container.appendChild(document.createElement('br'));
return;
}
if (part) {
let finalElement = document.createTextNode(part);
if (child.marks && child.marks.length > 0) {
child.marks.slice().reverse().forEach(mark => {
let wrapper;
switch (mark) {
case 'italic': wrapper = document.createElement('i'); break;
case 'sub': wrapper = document.createElement('sub'); break;
case 'sup': wrapper = document.createElement('sup'); break;
case 'strong': wrapper = document.createElement('strong'); break;
default: wrapper = document.createElement('span');
}
wrapper.appendChild(finalElement);
finalElement = wrapper;
});
}
container.appendChild(finalElement);
}
});
} else if (child._type === 'equationInline' && typeof child.equation === 'string') {
const equationSpan = document.createElement('span');
equationSpan.className = 'cufpc-equation';
// FIX: Use innerHTML to render the MathML tags, not textContent which escapes them.
equationSpan.innerHTML = child.equation.replace(/·/g, '×');
container.appendChild(equationSpan);
}
});
return container;
}
function renderAnswerCard(ans, idx) {
const card = document.createElement('div');
card.className = 'cufpc-answer-card';
const typesetAndReturn = (cardElement) => {
if (cardElement.innerHTML.includes('<math') && window.MathJax?.typesetPromise) {
setTimeout(() => {
try {
window.MathJax.typesetPromise([cardElement]).catch(err => console.error('CUFPC-latest: MathJax typeset failed:', err));
} catch (e) { console.error('CUFPC-latest: Error calling MathJax:', e); }
}, 50);
}
return cardElement;
};
const hdr = document.createElement('div');
hdr.textContent = `answer[${idx}]`;
hdr.style.fontWeight = '700';
hdr.style.marginBottom = '6px';
hdr.style.fontSize = '13px';
card.appendChild(hdr);
const contentContainer = document.createElement('div');
try {
if (typeof ans === 'object' && ans !== null && ans._type === 'p' && Array.isArray(ans.children)) {
const renderedBlock = renderPortableText(ans);
if (renderedBlock) {
contentContainer.appendChild(renderedBlock);
card.appendChild(contentContainer);
return typesetAndReturn(card);
}
}
if (typeof ans === 'string') {
const parsed = tryParsePossiblyEscapedJson(ans);
if (parsed !== null) return renderAnswerCard(parsed, idx);
let decoded = ans;
for (let i = 0; i < 3; i++) {
const next = decodeHtmlEntities(decoded);
if (next === decoded) break;
decoded = next;
}
contentContainer.innerHTML = decoded.replace(/\n/g, '<br>');
card.appendChild(contentContainer);
return typesetAndReturn(card);
}
if (typeof ans === 'object' && ans !== null) {
const pre = document.createElement('pre');
pre.style.whiteSpace = 'pre-wrap';
pre.style.fontFamily = 'monospace';
pre.style.fontSize = '12px';
pre.style.margin = '0';
try { pre.textContent = JSON.stringify(ans, null, 2); } catch (e) { pre.textContent = String(ans); }
contentContainer.appendChild(pre);
card.appendChild(contentContainer);
return typesetAndReturn(card);
}
contentContainer.textContent = String(ans);
card.appendChild(contentContainer);
return typesetAndReturn(card);
} catch (e) {
const fail = document.createElement('pre');
fail.style.whiteSpace = 'pre-wrap';
fail.textContent = `Failed to render answer: ${String(e)}\n\nraw: ${String(ans)}`;
card.appendChild(fail);
return typesetAndReturn(card);
}
}
function renderLatest(info) {
const p = ensurePanel();
p.dataset.latestRaw = info.rawText || '';
const meta = p.querySelector('#cufpc-meta');
const answersEl = p.querySelector('#cufpc-answers');
const debugEl = p.querySelector('#cufpc-debug');
const rawEl = p.querySelector('#cufpc-raw');
meta.innerHTML = '';
answersEl.innerHTML = '';
debugEl.style.display = 'none';
// Preserve raw view state
// rawEl.style.display = 'none';
const t = document.createElement('span'); t.textContent = new Date(info.t).toLocaleString(); meta.appendChild(t);
const u = document.createElement('span'); u.textContent = info.url ? (info.url.length > 80 ? info.url.slice(0,80) + '…' : info.url) : ''; meta.appendChild(u);
const st = document.createElement('span'); st.textContent = `status: ${info.status || ''}`; meta.appendChild(st);
let extracted = { answers: [], debug: {} };
if (info.json) extracted = extractAnswersFromJson(info.json);
else extracted = { answers: [], debug: { note: 'response is not valid JSON' } };
if (extracted.answers?.length > 0) {
extracted.answers.forEach((ans, idx) => {
const card = renderAnswerCard(ans, idx);
answersEl.appendChild(card);
});
} else {
const empty = document.createElement('div');
empty.className = 'cufpc-empty';
empty.textContent = 'No answers found at the expected path.';
answersEl.appendChild(empty);
debugEl.textContent = `Debug Info:\n` + JSON.stringify(extracted.debug || { note: 'No debug info available.'}, null, 2);
debugEl.style.display = 'block';
}
rawEl.textContent = info.rawText && info.rawText.length > MAX_RAW_PREVIEW ? info.rawText.slice(0, MAX_RAW_PREVIEW) + '\n\n…(truncated)' : info.rawText || '';
}
function bodyHasTargetOperation(bodyText) {
if (!bodyText) return false;
try {
const data = JSON.parse(bodyText);
return data?.operationName === TARGET_OPERATION;
} catch (e) {
return bodyText.includes(`"operationName":"${TARGET_OPERATION}"`);
}
}
function jsonLooksLikeContentUnit(obj) {
return !!(obj?.data?.contentUnit);
}
const _fetch = window.fetch;
window.fetch = function(input, init) {
const url = typeof input === 'string' ? input : input?.url;
let bodyPromise;
if (init?.body) {
try { bodyPromise = Promise.resolve(init.body); } catch(e) { bodyPromise = Promise.resolve(null); }
} else {
bodyPromise = Promise.resolve(null);
}
return bodyPromise.then(bodyText => {
const thinksItShouldWatch = bodyHasTargetOperation(bodyText);
return _fetch.apply(this, arguments).then(response => {
try {
const cloned = response.clone();
cloned.text().then(txt => {
let parsed = null;
try { parsed = JSON.parse(txt); } catch (e) {}
if (thinksItShouldWatch || jsonLooksLikeContentUnit(parsed)) {
renderLatest({ t: Date.now(), url, status: response.status, json: parsed, rawText: txt });
}
}).catch(()=>{});
} catch (e) {}
return response;
});
});
};
const XHRProto = XMLHttpRequest.prototype;
const _open = XHRProto.open;
const _send = XHRProto.send;
XHRProto.open = function(method, url) {
this._cufpc_url = url;
return _open.apply(this, arguments);
};
XHRProto.send = function(body) {
this.addEventListener('load', () => {
try {
const thinksItShouldWatch = bodyHasTargetOperation(body);
const txt = this.responseText;
let parsed = null;
try { parsed = txt ? JSON.parse(txt) : null; } catch (e) {}
if (thinksItShouldWatch || jsonLooksLikeContentUnit(parsed)) {
renderLatest({ t: Date.now(), url: this._cufpc_url, status: this.status, json: parsed, rawText: txt });
}
} catch (e) {}
});
return _send.apply(this, arguments);
};
setTimeout(() => ensurePanel(), 500);
console.info('CUFPC-latest userscript active — capturing operationName=' + TARGET_OPERATION + ' (MathML rendering enabled via MathJax)');
})();