228 lines
7 KiB
TypeScript
228 lines
7 KiB
TypeScript
// 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<Page> {
|
||
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<string> {
|
||
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 };
|