// getPegasusPHPSession.ts import { secrets, sleep } from "bun"; import puppeteer, { Browser, Page } from "puppeteer"; import auth from "./auth.json"; // ——— Credentials from Bun secrets (unchanged) ——— const email = auth.email || (await secrets.get({ service: "estia", name: "email" })) || ""; const password = auth.password || (await secrets.get({ service: "estia", name: "password" })) || ""; // ——— Options ——— export type GetSessionOpts = { headless?: true | false | "new"; debugScreenshots?: boolean; timeoutMs?: number; }; // ——— Utility: screenshots only when debugging ——— async function shot(page: Page, name: string, enabled: boolean) { if (!enabled) return; try { await page.screenshot({ path: `./${name}.png`, fullPage: true }); } catch {} } // ——— Utility: wait for either navigation or one of the selectors ——— async function waitForNavOrSelector( page: Page, selectors: string[], timeout = 30_000 ): Promise<"navigated" | { selector: string }> { const selPromise = (async () => { const start = Date.now(); while (Date.now() - start < timeout) { for (const sel of selectors) { const el = await page.$(sel); if (el) return { selector: sel as string }; } await page.waitForTimeout(150); } throw new Error("timeout waiting for selectors"); })(); const navPromise = page .waitForNavigation({ waitUntil: "domcontentloaded", timeout }) .then(() => "navigated" as const); try { return await Promise.race([selPromise, navPromise]); } catch { // If both rejected (rare), try a small grace wait await page.waitForTimeout(300); return "navigated"; } } // ——— Utility: resilient click that retries across navigations ——— async function clickResilient(page: Page, selector: string, { timeout = 10_000 }: { timeout?: number } = {}) { const end = Date.now() + timeout; while (Date.now() < end) { try { await page.waitForSelector(selector, { visible: true, timeout: 1000 }); await page.click(selector, { delay: 20 }); return; } catch (err: any) { const msg = String(err?.message || ""); // These are fine — we’ll retry until timeout if ( msg.includes("Execution context was destroyed") || msg.includes("Inspected target navigated or closed") || msg.includes("Cannot find context with specified id") ) { await page.waitForTimeout(150); continue; } // If element isn't there yet, keep looping if (msg.includes("waiting for selector")) { await page.waitForTimeout(150); continue; } // Unknown error — bubble up throw err; } } throw new Error(`Timeout clicking ${selector}`); } // ——— Utility: wait until any page returns to the Pegasus host ——— async function pickPegasusPage(browser: Browser, timeout = 30_000): Promise { const end = Date.now() + timeout; while (Date.now() < end) { const pages = await browser.pages(); for (const p of pages) { const url = p.url(); if (url.includes("learning.estia.fr")) return p; } await new Promise((r) => setTimeout(r, 300)); } throw new Error("Timeout waiting to return to learning.estia.fr"); } // ——— Main ——— export async function getPegasusPHPSession({ headless = "new", debugScreenshots = false, timeoutMs = 60_000, }: GetSessionOpts = {}): Promise { let browser: Browser | undefined; try { browser = await puppeteer.launch({ headless, args: ["--no-sandbox", "--disable-setuid-sandbox"], }); const page = await browser.newPage(); page.setDefaultTimeout(timeoutMs); // 1) Open Pegasus await page.goto("https://learning.estia.fr/pegasus/index.php", { waitUntil: "networkidle2", }); await shot(page, "01_pegasus_home", debugScreenshots); // 2) Click Microsoft login (allow same-tab or new-tab) const msBtn = "a.authlink[href*='o365Auth.php']"; await page.waitForSelector(msBtn, { visible: true }); await page.click(msBtn); // 2.1) If a new AAD tab opens, switch to it let authPage: Page = page; const newTarget = await browser .waitForTarget((t) => /login\.microsoftonline\.com/i.test(t.url()), { timeout: 10_000, }) .catch(() => undefined); if (newTarget) { const p = await newTarget.page(); if (p) authPage = p; } else { // Same tab flow await page.waitForNavigation({ waitUntil: "domcontentloaded" }).catch(() => {}); } await shot(authPage, "02_after_click_ms", debugScreenshots); // 3) Email await authPage.waitForSelector('input[name="loginfmt"]', { visible: true }); await authPage.type('input[name="loginfmt"]', email, { delay: 20 }); await authPage.keyboard.press("Enter"); // (Optional) tenant account picker await authPage .waitForSelector("#i0118, div[role='button'][data-telemetryid='UserTile']", { timeout: 5000 }) .catch(() => {}); const tile = await authPage.$("div[role='button'][data-telemetryid='UserTile']"); if (tile) { await clickResilient(authPage, "div[role='button'][data-telemetryid='UserTile']", { timeout: 5_000, }); } await sleep(2000); // Wait a bit for the transition // 4) Password await authPage.waitForSelector("#i0118", { visible: true }); await authPage.type("#i0118", password, { delay: 20 }); await authPage.keyboard.press("Enter"); await shot(authPage, "03_after_password", debugScreenshots); await sleep(2000); // Wait a bit for the transition // 5) KMSI (Stay signed in) — prefer "No", else "Yes". // We *don’t* run big evaluate() here; we click selectors resiliently. const kmsiCandidates = ["#idBtn_Back", "#idSIButton9"]; const kmsiOutcome = await waitForNavOrSelector(authPage, kmsiCandidates, 20_000).catch( () => "navigated" as const ); if (kmsiOutcome !== "navigated") { const toClick = kmsiOutcome.selector === "#idBtn_Back" ? "#idBtn_Back" : (await authPage.$("#idBtn_Back")) ? "#idBtn_Back" : "#idSIButton9"; try { await clickResilient(authPage, toClick, { timeout: 8_000 }); } catch { // If we miss it due to instant nav, that’s okay } } await shot(authPage, "04_after_kmsi", debugScreenshots); // 6) Back to Pegasus (could happen in any tab) let finalPage: Page; try { finalPage = await pickPegasusPage(browser, 30_000); } catch { // As a fallback, let the current authPage settle await authPage.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 10_000 }).catch(() => {}); finalPage = authPage; } // Network settle try { await finalPage.waitForNetworkIdle({ idleTime: 1000, timeout: 30_000 }); } catch {} await shot(finalPage, "05_back_on_pegasus", debugScreenshots); // 7) Cookies → PHPSESSID const cookies = await finalPage.cookies("https://learning.estia.fr"); const php = cookies.find((c) => c.name.toLowerCase() === "phpsessid"); if (!php?.value) { throw new Error("PHPSESSID cookie not found. Are you logged in?"); } return php.value; } finally { if (browser) { try { await browser.close(); } catch {} } } } const token = await getPegasusPHPSession({ headless: true, debugScreenshots: false }); export { token };