diff --git a/files/scripts/webuntis/getbearer.js b/files/scripts/webuntis/getbearer.js new file mode 100644 index 0000000..a1766a2 --- /dev/null +++ b/files/scripts/webuntis/getbearer.js @@ -0,0 +1,457 @@ +// getbearer.js — Browserless /function (Puppeteer) +// output = "png" | "json" (default: "json") +// - "png": nach Schulwahl + Loginversuch Screenshot als image/png +// - "json": bearer/diag/seenAuth + screenshot_b64 + +export default async function ({ page, context }) { + const { + url = "https://webuntis.com/#/basic/login", + username = "LehmanLuc", + password = "", + school = "LMG Crailsheim", // ggf. "Lise-Meitner-Gymnasium" + debug = true, + timeoutMs = 60000, + maxTimeout = 15000, + screenshotFullPage = true, + output = "json" // "png" | "json" + } = context || {}; + + const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + const diag = { steps: [], schoolHint: null, frameUrls: [], errors: [] }; + const seenAuth = []; + let bearer = null; + + // ---------- Hardening ---------- + try { await page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"); } catch {} + try { await page.setViewport({ width: 1366, height: 900, deviceScaleFactor: 1 }); } catch {} + try { page.setDefaultTimeout(Math.max(maxTimeout, timeoutMs)); } catch {} + + // ---------- Network taps ---------- + page.on("request", (req) => { + try { + const a = req.headers()["authorization"]; + if (!bearer && a?.startsWith("Bearer ")) { + bearer = a.slice(7); + seenAuth.push({ t: "req", url: req.url() }); + } + } catch {} + }); + page.on("response", async (res) => { + try { + if (bearer) return; + const a = res.request().headers()["authorization"]; + if (a?.startsWith("Bearer ")) { + bearer = a.slice(7); + seenAuth.push({ t: "res-reqhdr", url: res.url() }); + return; + } + const ct = (res.headers()["content-type"] || "").toLowerCase(); + if (ct.includes("application/json")) { + const data = await res.json().catch(() => null); + const tok = data?.access_token || data?.token || data?.idToken || data?.id_token; + if (tok) { bearer = tok; seenAuth.push({ t: "res-json", url: res.url() }); } + } + } catch {} + }); + + // ---------- Helpers ---------- + async function queryDeepHandle(frame, predicateFn) { + const handle = await frame.evaluateHandle((predSrc) => { + const pred = eval(predSrc); + const visit = (root) => { + for (const el of root.querySelectorAll("*")) { + try { if (pred(el)) return el; } catch {} + if (el.shadowRoot) { const f = visit(el.shadowRoot); if (f) return f; } + } + return null; + }; + return visit(document); + }, predicateFn.toString()); + return handle.asElement(); + } + + async function takePngBuffer() { + try { return await page.screenshot({ type: "png", fullPage: screenshotFullPage }); } + catch { return null; } + } + + const typeInto = async (el, val) => { + await el.focus().catch(() => {}); + await el.click({ clickCount: 3 }).catch(() => {}); + await el.type(val, { delay: 15 }).catch(() => {}); + try { + await el.evaluate((e,v)=>{ + const last = e.value; + e.value = v; + e.dispatchEvent(new Event('input', {bubbles:true})); + if (last !== v) e.dispatchEvent(new Event('change', {bubbles:true})); + if (e.form) e.form.dispatchEvent(new Event('input', {bubbles:true})); + }, val); + } catch {} + }; + + // Aktion + Navigation gekoppelt ausführen (verhindert "execution context destroyed") + async function withNav(page, action, waitMs = maxTimeout) { + const navP = page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: waitMs }).catch(() => {}); + const hostP = page.waitForFunction(() => { + try { + const h = location.hostname; + return /^[^.]+\.(webuntis|untis)\.com$/i.test(h) && !/^(www\.)?webuntis\.com$/i.test(h); + } catch { return false; } + }, { timeout: waitMs }).catch(() => {}); + const actP = (async () => { try { await action(); } catch {} })(); + await Promise.race([navP, hostP]); + await Promise.allSettled([navP, hostP, actP]); + } + + async function waitForRedirectOrPwd(page, timeout = 20000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const host = new URL(page.url()).hostname; + if (/^[^.]+\.(webuntis|untis)\.com$/i.test(host) && !/^(www\.)?webuntis\.com$/i.test(host)) return true; + } catch {} + for (const fr of page.frames()) { + const pwd = await queryDeepHandle(fr, (el) => el.tagName === "INPUT" && (el.type?.toLowerCase?.() === "password")); + if (pwd) return true; + } + await sleep(300); + } + return false; + } + + // Robuste Schulwahl + async function pickSchool(page, schoolHint, diag) { + if (!schoolHint) { diag.steps.push("no-school-hint"); return false; } + + // 1) Suchfeld finden (shadowDOM tauglich) + let input = null; + for (const fr of page.frames()) { + const h = await queryDeepHandle(fr, (el) => { + if (el.tagName !== "INPUT") return false; + const type = (el.getAttribute("type") || "").toLowerCase(); + const role = (el.getAttribute("role") || "").toLowerCase(); + const ph = (el.getAttribute("placeholder") || "").toLowerCase(); + const ar = (el.getAttribute("aria-label") || "").toLowerCase(); + return type === "search" || role === "combobox" || /search|schule|school/.test(ph+ar); + }); + if (h) { input = h; break; } + } + if (!input) { diag.steps.push("school-input-not-found"); return false; } + + // 2) Tippen + diag.steps.push("school-input-found"); + try { await input.focus(); } catch {} + try { await input.click({ clickCount: 3 }); } catch {} + await page.keyboard.type(String(schoolHint), { delay: 25 }); + await sleep(700); + + // 3) Erste sichtbare Suggestion unter dem Input per BBox ermitteln + async function getBox(elHandle) { try { return await elHandle.boundingBox(); } catch { return null; } } + async function isVisible(el) { + try { + return await el.evaluate((e) => { + const s = window.getComputedStyle(e); + const r = e.getBoundingClientRect(); + return s && s.display !== "none" && s.visibility !== "hidden" && r.width > 1 && r.height > 1; + }); + } catch { return false; } + } + + const inputBox = await getBox(input); + let best = null; // { frame, handle, box, text, key } + const until = Date.now() + 6000; + + while (!best && Date.now() < until) { + for (const fr of page.frames()) { + let handles = []; + for (const sel of ['[role="option"]', '[role="listitem"]', 'li', 'div[role="option"]']) { + try { const arr = await fr.$$(sel); if (arr?.length) handles.push(...arr); } catch {} + } + for (const h of handles) { + try { + if (!(await isVisible(h))) { await h.dispose().catch(()=>{}); continue; } + const box = await getBox(h); + if (!box || !inputBox) { await h.dispose().catch(()=>{}); continue; } + if (box.y < inputBox.y + inputBox.height - 2) { await h.dispose().catch(()=>{}); continue; } + const dist = Math.abs(box.y - (inputBox.y + inputBox.height)); + const text = (await h.evaluate(e => (e.textContent || "").trim())).toLowerCase(); + const hint = String(schoolHint).toLowerCase(); + const aliases = ["lise", "meitner", "lise-meitner", "gymnasium", "lmg", "crailsheim"]; + const score = (text.includes(hint) || aliases.some(a => text.includes(a))) ? 0 : 1; + const key = score * 100000 + dist; + if (!best || key < best.key) best = { frame: fr, handle: h, box, text, key }; + else await h.dispose().catch(()=>{}); + } catch { try { await h.dispose(); } catch {} } + } + } + if (!best) await sleep(120); + } + + let clicked = false; + if (best?.handle) { + try { + const { x, y, width, height } = best.box; + await withNav(page, async () => { + await page.mouse.move(Math.round(x + width / 2), Math.round(y + height / 2)); + await page.mouse.click(Math.round(x + width / 2), Math.round(y + height / 2)); + }, maxTimeout); + clicked = true; + diag.steps.push("school-suggestion-clicked-bbox:" + (best.text || "")); + } catch (e) { + diag.errors.push("bbox-click-failed:" + (e?.message || e)); + try { + await withNav(page, async () => { await best.handle.click(); }, maxTimeout); + clicked = true; + diag.steps.push("school-suggestion-clicked-handle"); + } catch {} + } finally { + try { await best.handle.dispose(); } catch {} + } + } + + if (!clicked) { + // Sicherstellen, dass das Feld Fokus hat; dann ArrowDown+Enter + Nav abwarten + try { await input.focus(); } catch {} + await withNav(page, async () => { + await page.keyboard.press("ArrowDown"); + await sleep(120); + await page.keyboard.press("Enter"); + }, maxTimeout); + diag.steps.push("school-enter-fallback"); + } + + await sleep(500); // nach Navigation alte Handles verwerfen + return await waitForRedirectOrPwd(page, 20000); + } + + // ---- Login-Felder finden & ausfüllen (robust) ---- + // ---- Login-Felder finden & ausfüllen (robust, ohne :has-text) ---- + async function findLoginElements() { + // 1) direkte, einfache Selektoren + const userSel = [ + 'input[placeholder*="Benutzer" i]', + 'input[aria-label*="Benutzer" i]', + 'input[name*="user" i]', + 'input[id*="user" i]', + 'input[type="email"]', + 'input[type="text"]' + ].join(','); + + const passSel = [ + 'input[placeholder*="Passwort" i]', + 'input[aria-label*="Passwort" i]', + 'input[type="password"]' + ].join(','); + + let u = await page.$(userSel); + let p = await page.$(passSel); + + // 2) Shadow-DOM-Fallback + if (!u || !p) { + for (const fr of page.frames()) { + if (!u) u = await queryDeepHandle(fr, (el) => { + if (el.tagName !== "INPUT") return false; + const t = (el.getAttribute("type")||"").toLowerCase(); + const txt = ((el.getAttribute("placeholder")||"") + " " + (el.getAttribute("aria-label")||"")).toLowerCase(); + return (t==="text"||t==="email"||t==="") && /user|benutzer|name/.test(txt); + }); + if (!p) p = await queryDeepHandle(fr, (el) => el.tagName==="INPUT" && (el.type||"").toLowerCase()==="password"); + if (u && p) break; + } + } + + // 3) Submit-Button: zuerst type=submit, sonst per Textinhalt + let b = await page.$('button[type="submit"], input[type="submit"]'); + + if (!b) { + // scannt sichtbare Buttons und matcht Textinhalt + const btnByText = async (root) => { + const LABELS = ["login","anmelden","einloggen","weiter","sign in","absenden"]; + const nodes = root.querySelectorAll('button, input[type="submit"]'); + for (const n of nodes) { + let txt = (n.innerText || n.textContent || "").toLowerCase().trim(); + if (!txt && n.getAttribute) txt = (n.getAttribute("value")||"").toLowerCase().trim(); + if (!txt) continue; + if (LABELS.some(l=>txt.includes(l))) { + const cs = getComputedStyle(n); + if (cs.display!=="none" && cs.visibility!=="hidden" && !n.disabled) return n; + } + } + return null; + }; + + // zuerst im Haupt-DOM + b = await page.evaluateHandle(btnByText, document).then(h=>h.asElement()).catch(()=>null); + + // Shadow-DOM-Fallback + if (!b) { + for (const fr of page.frames()) { + const h = await fr.evaluateHandle(() => { + const visit = (root) => { + const el = (function(root){ + const LABELS = ["login","anmelden","einloggen","weiter","sign in","absenden"]; + const nodes = root.querySelectorAll('button, input[type="submit"]'); + for (const n of nodes) { + let txt = (n.innerText || n.textContent || "").toLowerCase().trim(); + if (!txt && n.getAttribute) txt = (n.getAttribute("value")||"").toLowerCase().trim(); + if (!txt) continue; + if (LABELS.some(l=>txt.includes(l))) { + const cs = getComputedStyle(n); + if (cs.display!=="none" && cs.visibility!=="hidden" && !n.disabled) return n; + } + } + return null; + })(root); + if (el) return el; + for (const e of root.querySelectorAll("*")) { + if (e.shadowRoot) { + const f = visit(e.shadowRoot); + if (f) return f; + } + } + return null; + }; + return visit(document); + }).catch(()=>null); + if (h) { b = h.asElement(); break; } + } + } + + // 4) Wenn noch kein Button: im selben
wie Passwortfeld + if (!b && p) { + const form = await p.evaluateHandle(e => e.closest('form')); + if (form) { + const hb = await form.asElement().$('button, input[type="submit"]'); + if (hb) b = hb; + await form.dispose().catch(()=>{}); + } + } + } + + return { u, p, b }; + } + + + // ---------- Main flow ---------- + try { + // Load (Blank-Heuristik) + await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs }).catch(() => {}); + await sleep(700); + try { + const isBlank = await page.evaluate(() => document.body && document.body.children.length < 2); + if (isBlank) { diag.steps.push("blank-reload"); await page.reload({ waitUntil: "domcontentloaded" }).catch(()=>{}); await sleep(500); } + } catch {} + + // Schul-Hint bestimmen + let schoolHint = school || null; + try { + const u = new URL(url); + const m = u.hostname.match(/^([^.]+)\.webuntis\.com$/i); + if (!schoolHint && m) schoolHint = m[1]; + } catch {} + diag.schoolHint = schoolHint || null; + + // Consent (best effort) + for (const sel of [ + 'button[aria-label*="Akzept"]','button[title*="Akzept"]', + 'button[aria-label*="Accept" i]','button[title*="Accept" i]' + ]) { try { await page.click(sel, { timeout: 500 }); diag.steps.push("clicked-consent"); break; } catch {} } + + // Passwortfeld schon sichtbar? + let hasPwd = false; + for (const fr of page.frames()) { + const pwd = await queryDeepHandle(fr, (el) => el.tagName === "INPUT" && (el.type?.toLowerCase?.() === "password")); + if (pwd) { hasPwd = true; break; } + } + if (!hasPwd) { + const ok = await pickSchool(page, schoolHint, diag); + if (!ok) diag.steps.push("school-pick-maybe-failed"); + await sleep(600); + } + + // Login-Felder und Submit + let { u: userEl, p: passEl, b: btnEl } = await findLoginElements(); + if (!userEl || !passEl) { + diag.steps.push("login-fields-missing"); + } else { + await typeInto(userEl, username); + await typeInto(passEl, password); + + const submitAction = async () => { + if (btnEl) { + try { await btnEl.click(); } + catch { try { await passEl.press("Enter"); } catch {} } + } else { + try { await passEl.press("Enter"); } catch {} + } + }; + + const navP = page.waitForNavigation({ waitUntil: "networkidle0", timeout: maxTimeout }).catch(()=>{}); + const bearerP = new Promise(resolve=>{ + const t = setTimeout(()=>resolve(false), maxTimeout); + const off = page.on("request", req=>{ + const a = req.headers()?.authorization||""; + if (a.startsWith("Bearer ")) { clearTimeout(t); resolve(true); } + }); + }); + const errBannerP = page.waitForSelector('[role="alert"], .error, .un-input-group__error', { timeout: 12000 }) + .then(()=>true).catch(()=>false); + + await submitAction(); + const results = await Promise.allSettled([navP, bearerP, errBannerP]); + const hadErr = results[2]?.value === true; + if (hadErr) diag.steps.push("login-error-banner-seen"); + } + + // Storage-Fallback + if (!bearer) { + try { + for (const fr of page.frames()) { + const tok = await fr.evaluate(() => { + const scan = (s) => { + for (let i=0;inull); + if (tok) { bearer = tok; diag.steps.push("token-from-storage"); break; } + } + } catch {} + } + + // ---------- OUTPUT ---------- + if (output === "png") { + const buf = await takePngBuffer(); + return { data: buf || Buffer.from([]), type: "image/png" }; + } + + + + const buf = await takePngBuffer(); + const screenshot_b64 = buf ? "data:image/png;base64," + buf.toString("base64") : null; + + const json = debug + ? { bearer: bearer || null, seenAuth, diag, screenshot_b64 } + : { bearer: bearer || null, screenshot_b64 }; + return { data: json, type: "application/json" }; + + } catch (err) { + diag.errors.push(err?.message || String(err)); + const buf = await takePngBuffer(); + if (output === "png") return { data: buf || Buffer.from([]), type: "image/png" }; + const screenshot_b64 = buf ? "data:image/png;base64," + buf.toString("base64") : null; + return { data: { error: err?.message || String(err), diag, seenAuth, bearer: null, screenshot_b64 }, type: "application/json" }; + } + } + \ No newline at end of file