Skip to content

Commit 76c1c58

Browse files
spotta85claude
andauthored
Feature/dap 105 make the run audit button work end to end from the popup (#201)
* fixed issue * overhualed scraping structure to not open background tabs and instead use a fetch with credentials approach. * docs + fix the readme format. * initial changes * made popup not do auth checks for speed and fixed issue with "syncing" text staying on screen too long. * added concurrency to fetches for faster audit sync --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c8a6bce commit 76c1c58

10 files changed

Lines changed: 326 additions & 105 deletions

File tree

docs/audit_scraping.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ the session-cookie watcher in `session.ts` picks things back up after re-login.
5050

5151
| File | Role |
5252
|---|---|
53-
| `features/audit-scraping/content-controller.ts` | Routes page loads; serves `FETCH_AUDIT` |
53+
| `features/audit-scraping/content-controller.ts` | Routes page loads; serves `FETCH_AUDIT` / `RUN_AUDIT_VIA_FETCH` / `FETCH_AUDIT_OPTIONS` |
5454
| `features/audit-scraping/audit-history-sync.ts` | History sync, run detection, polling, results fetch |
55+
| `features/audit-scraping/audit-runner.ts` | Submits default/custom audit runs; lists form options |
5556
| `features/audit-scraping/audit-history-parser.ts` | History table → `AuditHistoryEntry[]` |
5657
| `features/audit-scraping/audit-page-parser.ts` | Results DOM → `CachedAuditData` |
5758
| `features/audit-scraping/background-controller.ts` | Batch orchestration, login gate, run-audit button |

domain/audit.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ export interface AuditHistoryData {
5858
error?: string;
5959
}
6060

61+
// UT form values are submitted verbatim; degree-plan codes include trailing spaces.
62+
export interface CustomAuditRunRequest {
63+
catalog: string;
64+
college: string;
65+
degreePlan: string;
66+
minor?: string;
67+
certificate?: string;
68+
includeCurrent?: boolean;
69+
includeFuture?: boolean;
70+
includePlanned?: boolean;
71+
}
72+
6173
export function getAuditDisplayName(
6274
entry: AuditHistoryEntry | undefined,
6375
): string | null {

features/audit-scraping/audit-history-sync.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ const AUDIT_RESULTS_URL =
2222
// controller clicks it programmatically, so detecting and clicking must agree.
2323
export const RUN_AUDIT_BUTTON_SELECTOR = ".run_button";
2424

25-
const POLL_INITIAL_DELAY_MS = 1_000;
2625
const POLL_INTERVAL_MS = 500;
2726
const POLL_WINDOW_MS = 90_000;
2827

@@ -125,12 +124,6 @@ function pollForRequestedAudit(startedAt: number): Promise<void> {
125124
};
126125

127126
try {
128-
const initialDelay = Math.max(
129-
0,
130-
startedAt + POLL_INITIAL_DELAY_MS - Date.now(),
131-
);
132-
await new Promise((resolve) => setTimeout(resolve, initialDelay));
133-
134127
const deadline = startedAt + POLL_WINDOW_MS;
135128
while (Date.now() < deadline) {
136129
if (await tick()) break;
@@ -145,18 +138,22 @@ function pollForRequestedAudit(startedAt: number): Promise<void> {
145138
})());
146139
}
147140

141+
// Marks a run as pending and polls for its result. The marker survives page
142+
// navigation; resumePendingAuditPoll picks it up on the next audits page.
143+
export async function markAuditRunPending(): Promise<void> {
144+
const startedAt = Date.now();
145+
await getPendingRunItem().setValue(startedAt);
146+
void pollForRequestedAudit(startedAt);
147+
}
148+
148149
// Marks a run as pending when UT's run button is clicked, then polls for it.
149150
export function watchForAuditRunClicks(document: Document): void {
150151
document.addEventListener(
151152
"click",
152153
(event) => {
153154
if (!(event.target instanceof Element)) return;
154155
if (!event.target.closest(RUN_AUDIT_BUTTON_SELECTOR)) return;
155-
156-
const startedAt = Date.now();
157-
void getPendingRunItem()
158-
.setValue(startedAt)
159-
.then(() => pollForRequestedAudit(startedAt));
156+
void markAuditRunPending();
160157
},
161158
{ capture: true },
162159
);
@@ -227,9 +224,7 @@ export async function startAuditHistorySync(document: Document): Promise<void> {
227224
new URLSearchParams(document.location.search).get("submit_success") ===
228225
"Y";
229226
if (justSubmitted || audits.some((audit) => !hasAuditResult(audit))) {
230-
const startedAt = Date.now();
231-
await getPendingRunItem().setValue(startedAt);
232-
void pollForRequestedAudit(startedAt);
227+
await markAuditRunPending();
233228
}
234229
}
235230
observeHistoryTable(document);
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Submits audit runs to UT via authenticated same-origin fetches. Runs in a
2+
// content script on a UT page — the only context whose origin passes UT's
3+
// CSRF checks (extension-origin POSTs get 403).
4+
import type { CustomAuditRunRequest } from "@/domain/audit";
5+
import { isLoginPage } from "@/features/session/session";
6+
import {
7+
markAuditRunPending,
8+
RUN_AUDIT_BUTTON_SELECTOR,
9+
} from "./audit-history-sync";
10+
11+
const RUN_PAGE_URL =
12+
"https://utdirect.utexas.edu/apps/degree/audits/submissions/student_individual/";
13+
14+
// The individual-audit form; its selects populate only when the page is
15+
// fetched with catalog+college query parameters.
16+
const CUSTOM_FORM_SELECTOR = "#single_request";
17+
18+
// Runs the user's default profile audit, or a custom one when options are
19+
// given. Marks successful submissions pending so history polling finds them.
20+
export async function runAudit(custom?: CustomAuditRunRequest): Promise<void> {
21+
if (custom) {
22+
await submitCustomAudit(custom);
23+
} else {
24+
await submitDefaultAudit();
25+
}
26+
await markAuditRunPending();
27+
}
28+
29+
async function submitDefaultAudit(): Promise<void> {
30+
const page = await fetchRunPage();
31+
const form = page.querySelector(RUN_AUDIT_BUTTON_SELECTOR)?.closest("form");
32+
if (!form) throw new Error("RUN_BUTTON_NOT_FOUND");
33+
34+
// Resolve the form's action against the fetched page, not the current one —
35+
// DOMParser documents inherit the creating page's base URL.
36+
const target = new URL(form.getAttribute("action") ?? "", RUN_PAGE_URL);
37+
await submitForm(form, target.toString());
38+
}
39+
40+
async function submitCustomAudit(
41+
options: CustomAuditRunRequest,
42+
): Promise<void> {
43+
const query = new URLSearchParams({
44+
catalog: options.catalog,
45+
college: options.college,
46+
});
47+
const page = await fetchRunPage(`?${query}`);
48+
const form = page.querySelector<HTMLFormElement>(CUSTOM_FORM_SELECTOR);
49+
if (!form) throw new Error("RUN_FORM_NOT_FOUND");
50+
51+
setSelect(form, "degree_plan", options.degreePlan);
52+
if (options.minor) setSelect(form, "minor", options.minor);
53+
if (options.certificate) setSelect(form, "certificate", options.certificate);
54+
setCheckbox(form, "current", options.includeCurrent ?? true);
55+
setCheckbox(form, "future", options.includeFuture ?? false);
56+
setCheckbox(form, "planned", options.includePlanned ?? false);
57+
58+
// The form posts to its own parameterized URL (action="").
59+
await submitForm(form, `${RUN_PAGE_URL}?${query}`);
60+
}
61+
62+
async function fetchRunPage(search = ""): Promise<Document> {
63+
const response = await fetch(`${RUN_PAGE_URL}${search}`, {
64+
credentials: "include",
65+
});
66+
if (!response.ok) throw new Error("RUN_FAILED");
67+
68+
const page = new DOMParser().parseFromString(
69+
await response.text(),
70+
"text/html",
71+
);
72+
if (isLoginPage(page)) throw new Error("AUTH_REQUIRED");
73+
return page;
74+
}
75+
76+
async function submitForm(
77+
form: HTMLFormElement,
78+
targetUrl: string,
79+
): Promise<void> {
80+
const body = new URLSearchParams();
81+
for (const [key, value] of new FormData(form)) {
82+
body.append(key, String(value));
83+
}
84+
// FormData omits the submit control's pair, which UT's views expect
85+
// (e.g. audit="Submit Audit").
86+
const submit = form.querySelector<HTMLInputElement | HTMLButtonElement>(
87+
'[type="submit"]',
88+
);
89+
if (submit?.name) body.append(submit.name, submit.value);
90+
91+
const response = await fetch(targetUrl, {
92+
method: "POST",
93+
credentials: "include",
94+
body,
95+
});
96+
// A successful submission 302s to the request-history page.
97+
if (response.ok && response.redirected && response.url.includes("/history/"))
98+
return;
99+
100+
const page = new DOMParser().parseFromString(
101+
await response.text(),
102+
"text/html",
103+
);
104+
throw new Error(isLoginPage(page) ? "AUTH_REQUIRED" : "RUN_FAILED");
105+
}
106+
107+
function setSelect(form: HTMLFormElement, name: string, value: string): void {
108+
const select = form.querySelector<HTMLSelectElement>(
109+
`select[name="${name}"]`,
110+
);
111+
if (!select) throw new Error("RUN_FORM_CHANGED");
112+
if (![...select.options].some((option) => option.value === value)) {
113+
// e.g. a degree plan UT doesn't offer for this catalog+college
114+
throw new Error("OPTION_NOT_AVAILABLE");
115+
}
116+
select.value = value;
117+
}
118+
119+
function setCheckbox(
120+
form: HTMLFormElement,
121+
name: string,
122+
checked: boolean,
123+
): void {
124+
const box = form.querySelector<HTMLInputElement>(`input[name="${name}"]`);
125+
if (box) box.checked = checked;
126+
}

0 commit comments

Comments
 (0)