// 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://mese.webuntis.com/WebUntis/?school=LMG+Crailsheim", username = "LehmanLuc", password = "", school = "LMG Crailsheim", // ggf. "Lise-Meitner-Gymnasium" debug = true, timeoutMs = 10000, maxTimeout = 3000, screenshotFullPage = false, 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" }; } }