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