#!/usr/bin/env node /** * Compass fetcher for the Compass / NHS project — patchright path. * * The pi-browser-harness (headless Chrome) can load the Compass login page but * Cloudflare answers the login POST with a managed challenge that headless * Chrome does not clear. patchright launches Chrome with the automation leaks * patched, and the login POST goes through. This script: * * 1. reuses a stored per-child browser session when one is supplied and still * valid, otherwise logs in once with the parent account * (COMPASS_USERNAME / COMPASS_PASSWORD), * 2. resolves the child's Compass userId from the parent landing page, * 3. fetches that child's pages, * 4. stores the raw HTML + a manifest with a SHA-256 and byte count per page, * 5. refreshes the per-child session state (``--state``) for the next scan. * * Read-only against the school: it only navigates and reads. * * Usage: * node scripts/compass-fetch-patchright.mjs --child Harry [--user-id 27038] * [--state var/session/harry.json] [--pages "name=url,..."] * * Environment (never commit these): * COMPASS_USERNAME / COMPASS_PASSWORD parent/guardian account * COMPASS_CHROME Chrome binary (default: Nix profile google-chrome) * COMPASS_LOGIN_URL default https://nhs-vic.compass.education/login.aspx * COMPASS_UA browser user-agent override * * Exit codes: 0 ok, 2 Cloudflare challenge not cleared, 3 second factor, * 4 login rejected, 1 fetch errors (a page failed or the child id is missing). */ import { chromium } from "patchright"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; const DEFAULT_URL = "https://nhs-vic.compass.education/login.aspx"; const DEFAULT_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; const DEFAULT_CHROME = "/etc/profiles/per-user/sam/bin/google-chrome"; const CHALLENGE = /just a moment|verifying|checking your browser|attention required/i; // Per-child pages. The calendar is deliberately NOT here: the parent calendar // page renders the visible child's calendars only (it defaults to the eldest), // so fetching it inside a younger child's run would leak the eldest's timetable // into that child's items. Per-child calendar attribution is deferred; the // calendar stays available through an explicit --pages override. The parent // landing page (/) is always stored as "home" right after login. const DEFAULT_PAGES = [ { name: "child-profile", url: "/Records/User.aspx?userId={userId}" }, { name: "learning-tasks", url: "/Records/UserNew.aspx?userId={userId}#learningTasks" }, ]; // TASKS_SERVICE is the Compass JSON service behind the Learning Tasks grid. The // grid itself is capped at LearningTask_Default_Records_Count (20) rows and, on // the parent page, mixes children. This endpoint returns every learning task // for one child with its submission status, so the overdue count can be checked // against the same child's count on the home page (AUD-114, FR-12). const TASKS_SERVICE = "/Services/LearningTasks.svc/GetAllLearningTasksByUserId"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sha256 = (s) => crypto.createHash("sha256").update(s, "utf8").digest("hex"); function parseArgs(argv) { const args = { child: "Harry", userId: null, out: path.resolve("var/raw"), state: null, pages: null, }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; if (a === "--child") args.child = argv[++i]; else if (a === "--user-id") args.userId = argv[++i]; else if (a === "--out") args.out = argv[++i]; else if (a === "--state") args.state = argv[++i]; else if (a === "--no-state") args.state = null; else if (a === "--pages") { // Split each entry on the FIRST "=" so a URL may carry a query string. args.pages = String(argv[++i]).split(",").map((p) => { const eq = p.indexOf("="); if (eq < 0) throw new Error(`--pages entry needs name=url: ${p}`); return { name: p.slice(0, eq), url: p.slice(eq + 1) }; }); } else throw new Error(`unknown argument: ${a}`); } return args; } async function waitPastChallenge(page, seconds) { for (let i = 0; i < seconds; i++) { await page.waitForTimeout(1000); const t = await page.title().catch(() => ""); if (t && !CHALLENGE.test(t)) return t; } return await page.title().catch(() => ""); } /** * waitForFeed waits for the home news feed to render. Compass loads the feed * client-side after the page shell, so storing the page too early drops it and * the parser (correctly) fails loudly. */ async function waitForFeed(page, ms) { try { await page.waitForSelector('[data-testid="feed-toolbar"], .MuiCardContent-root', { timeout: ms }); } catch { // The parser will fail loudly if the feed is genuinely missing. } await sleep(500); } async function storePage(page, outDir, slug, baseUrl) { const html = await page.content(); const state = { url: page.url(), title: await page.title() }; const bytes = Buffer.byteLength(html, "utf8"); const hash = sha256(html); fs.writeFileSync(path.join(outDir, `${slug}.html`), html); const meta = { slug, url: state.url, title: state.title, fetchedAt: new Date().toISOString(), bytes, sha256: hash, }; fs.writeFileSync(path.join(outDir, `${slug}.meta.json`), JSON.stringify(meta, null, 2) + "\n"); return meta; } /** * fetchLearningTasksService reads the full per-child learning-task list from the * Compass JSON service and stores it next to the HTML pages. It is read-only: * one POST with the child's userId and a large page limit. The request runs * inside the logged-in page (`fetch`, `credentials: include`) so it carries the * session cookies and the Compass request-capture headers; the browser * context's own API client is rejected with 403. The meta file records the * child's userId so the Go parser can tie the list to the child and to the home * page count. A non-200 response or a non-JSON body is recorded as an error. */ async function fetchLearningTasksService(page, baseUrl, outDir, userId) { const url = new URL(TASKS_SERVICE, baseUrl).toString(); const body = { userId: Number(userId), page: 1, start: 0, limit: 1000 }; const result = await page.evaluate( async ({ url, body }) => { const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(body), }); return { status: r.status, text: await r.text() }; }, { url, body }, ); const text = result.text; const meta = { slug: "learning-tasks-all", url, title: "Learning Tasks service", method: "POST", body, userId: String(userId), fetchedAt: new Date().toISOString(), bytes: Buffer.byteLength(text, "utf8"), sha256: sha256(text), status: result.status, taskCount: null, total: null, }; try { const parsed = JSON.parse(text); if (Array.isArray(parsed?.d?.data)) meta.taskCount = parsed.d.data.length; if (typeof parsed?.d?.total === "number") meta.total = parsed.d.total; } catch { // Not JSON: keep the raw body and let the caller fail loudly (FR-12). } fs.writeFileSync(path.join(outDir, "learning-tasks-all.json"), text); fs.writeFileSync(path.join(outDir, "learning-tasks-all.meta.json"), JSON.stringify(meta, null, 2) + "\n"); return meta; } /** * findChildUserId resolves a child name to a Compass userId from the parent * landing page, for example "Harrison ROLFE" -> 27038. The parent page groups * each child's links under a .parentChildActions block whose