de.lehmann.automation.ansib.../files/scripts/webuntis/getbearer.js

458 lines
18 KiB
JavaScript

// 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 <form> 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;i<s.length;i++){
const v = s.getItem(s.key(i));
if (!v) continue;
try {
const j = JSON.parse(v);
const t = j?.access_token || j?.token || j?.idToken || j?.id_token;
if (t) return t;
} catch {}
if (/^Bearer\s+/.test(v)) return v.replace(/^Bearer\s+/, "");
if (/eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*\./.test(v)) return v;
}
return null;
};
return scan(localStorage) || scan(sessionStorage);
}).catch(()=>null);
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" };
}
}