Use when comparing MyOpenMath gradebook assignments against an Aeries gradebook to find missing assignments, check category totals, or produce the read-only comparison artifacts required before gb-new-assignment or gb-pipeline.
Compare assignments between MyOpenMath (MOM) and Aeries without modifying either system. This skill expands MOM categories, extracts assignment names/points/dates, reads existing Aeries assignments, applies number-anchored matching, and produces both a markdown report and the temp
gb_compare_{gradebookNum}.jsonartifact consumed by later gradebook-sync stages.
gb-compare always runs before gb-new-assignment or gb-pipelinegrade-cloning/gradebook-comparison.mdC:\Users\shuff\grade-cloning\temp\gb_compare_{gradebookNum}.jsongb-new-assignment⚠️ Must NOT:
- Never click Add, Edit, Delete, Save, or any grading control in Aeries.
- Never navigate away from the current MOM or Aeries gradebook pages.
- Never report missing assignments from exact-string matching alone; use the number-anchored matcher.
- Never compare MOM point values to Aeries point values as an equality check; Aeries normalizes assignments to 100 points.
- Never lose the temp JSON shape or rename its top-level keys;
gb-new-assignmentdepends on them.- Never invent dates; if MOM settings fetch fails or is skipped, keep
assignedDate/dueDatenull in JSON and show—in markdown.- Never present this skill as making changes.
gb-compareis read-only and idempotent.
gb_compare_{gradebookNum}.json for the next stage.gradebookNum plus the Aeries base URL for artifact metadata.momPage, aeriesPage, gradebookNum, aeriesBaseconst pages = context.pages();
const momPage = pages.find((p) => p.url().includes('myopenmath.com'));
const aeriesPage = pages.find(
(p) => p.url().includes('aeries') && p.url().toLowerCase().includes('gradebook')
);
if (!momPage) throw new Error('No MyOpenMath tab found — open the MOM gradebook first.');
if (!aeriesPage) throw new Error('No Aeries Gradebook tab found — open Aeries first.');
await momPage.evaluate(() => document.title);
await aeriesPage.evaluate(() => document.title);
const gradebookNum = aeriesPage.url().match(/gradebook\/(\d+)/)?.[1] ?? 'unknown';
const aeriesBase = new URL(aeriesPage.url()).origin;
#availshow to value 2 so MOM shows All assignments.catMap from span.cattothdr headers and the parent th class (cat1, cat2, cat3).[Expand] links until none remain.th[data-pts], using the first text node only for the clean assignment name.aid and cid from the settings link so dates can be fetched later.catMap, momAssignmentsawait momPage.evaluate(() => {
const sel = document.querySelector('#availshow');
if (sel) {
sel.value = '2';
sel.dispatchEvent(new Event('change'));
}
});
await new Promise((r) => setTimeout(r, 1500));
const catMap = await momPage.evaluate(() => {
const map = {};
document.querySelectorAll('span.cattothdr').forEach((span) => {
const text = span.textContent.trim();
const match = text.match(/^(\S+)\s+(\d+%?)$/);
if (!match) return;
const th = span.closest('th');
const cls = ['cat1', 'cat2', 'cat3'].find((c) => th.classList.contains(c));
if (cls) map[cls] = { name: match[1], weight: match[2] };
});
return map;
});
while (true) {
const clicked = await momPage.evaluate(() => {
const links = [...document.querySelectorAll('a')].filter((a) =>
a.textContent.includes('[Expand]')
);
links.forEach((a) => a.click());
return links.length;
});
if (clicked === 0) break;
await new Promise((r) => setTimeout(r, 500));
}
const momAssignments = await momPage.evaluate((catMap) => {
const results = [];
document.querySelectorAll('th[data-pts]').forEach((th) => {
const nameDiv = th.querySelector('div');
const name = nameDiv ? nameDiv.childNodes[0].textContent.trim() : '';
const pts = th.getAttribute('data-pts');
const cls = ['cat1', 'cat2', 'cat3'].find((c) => th.classList.contains(c));
const category = cls && catMap[cls] ? catMap[cls].name : 'UNKNOWN';
const weight = cls && catMap[cls] ? catMap[cls].weight : '';
const settingsLink = th.querySelector('a[href*="moasettings"]');
let aid = null;
let cid = null;
if (settingsLink) {
const params = new URLSearchParams(settingsLink.href.split('?')[1]);
aid = params.get('aid');
cid = params.get('cid');
}
results.push({ name, pts, category, weight, aid, cid });
});
return results;
}, catMap);
momAssignments with aid/cidmoasettings.php page while already authenticated in MOM, then parse sdate as assignedDate and edate as dueDate.momAssignmentsWithDatesconst momAssignmentsWithDates = await momPage.evaluate(async (assignments) => {
return Promise.all(
assignments.map(async (a) => {
if (!a.aid || !a.cid) return { ...a, assignedDate: null, dueDate: null };
const url = `https://www.myopenmath.com/course/moasettings.php?cid=${a.cid}&aid=${a.aid}`;
try {
const html = await fetch(url).then((r) => r.text());
const sdateMatch = html.match(/name="sdate"[^>]*value="([^"]+)"/);
const edateMatch = html.match(/name="edate"[^>]*value="([^"]+)"/);
return {
...a,
assignedDate: sdateMatch ? sdateMatch[1] : null,
dueDate: edateMatch ? edateMatch[1] : null,
};
} catch {
return { ...a, assignedDate: null, dueDate: null };
}
})
);
}, momAssignments);
Date rules:
sdate = assigned-on dateedate = due datenull and render markdown dates as —th[data-an] headers. Use textContent.trim() for assignment names so HTML entities decode correctly (& → &).aeriesAssignmentsconst aeriesAssignments = await aeriesPage.evaluate(() => {
const results = [];
document.querySelectorAll('th[data-an]').forEach((th) => {
const number = th.getAttribute('data-an');
const name = th.textContent.trim();
results.push({ number, name });
});
return results;
});
matched, missingfunction normalize(s) {
return s.toLowerCase().replace(/--/g, ' ').replace(/[(),&]/g, ' ').replace(/\s+/g, ' ').trim();
}
function extractNumbers(s) {
return [...s.matchAll(/\d+\.?\d*/g)].map((m) => m[0]);
}
function extractWords(s) {
return normalize(s).split(' ').filter((w) => w.length > 1 && !/^\d/.test(w));
}
function matchAssignments(momList, aeriesList) {
const matched = [];
const missing = [];
const used = new Set();
for (const mom of momList) {
const mNums = extractNumbers(mom.name);
const mWords = extractWords(mom.name);
let best = null;
let bestScore = 0;
for (let i = 0; i < aeriesList.length; i++) {
if (used.has(i)) continue;
const a = aeriesList[i];
const aNums = extractNumbers(a.name);
const overlap = mNums.filter((n) => aNums.includes(n)).length;
const numScore = overlap / Math.max(mNums.length, aNums.length, 1);
if (numScore === 0) continue;
const aWords = extractWords(a.name);
const wordOverlap = mWords.filter((w) => aWords.includes(w)).length;
const wordScore = wordOverlap / Math.max(mWords.length, aWords.length, 1);
const score = numScore * 0.7 + wordScore * 0.3;
if (score > bestScore) {
bestScore = score;
best = { idx: i, ...a };
}
}
if (best && bestScore >= 0.4) {
used.add(best.idx);
matched.push({ mom, aeries: best, score: bestScore });
} else {
missing.push(mom);
}
}
return { matched, missing };
}
Matching rules:
0.4Homework 5.1, 5.2 (part 1) vs 5.1 & 5.2 Confidence Intervalsmatched, missing, category totals, and MOM dates if availablegrade-cloning/gradebook-comparison.mdUse this report format:
# Gradebook Comparison: MyOpenMath → Aeries
**Course**: {course name from page title}
**Date**: {today's date}
---
## Assignments Missing from Aeries
| MyOpenMath Name | Category | MOM Points | Assigned On | Due Date | Status |
|-----------------|----------|------------|-------------|----------|--------|
| {name} | **{CAT}** | {pts} | {assignedDate or —} | {dueDate or —} | ❌ NOT IN AERIES |
**{count} assignment(s) need to be added to Aeries.**
---
## Full Assignment Comparison
| # | MyOpenMath Name | Cat | MOM Pts | Assigned On | Due Date | Aeries Name | Aeries # | In Aeries? |
|---|-----------------|-----|---------|-------------|----------|-------------|----------|------------|
| {n} | {mom_name} | {cat} | {pts} | {assignedDate or —} | {dueDate or —} | {aeries_name or —} | {# or —} | ✅ or ❌ MISSING |
---
## Summary by Category
| Category | Weight | Total in MOM | Total in Aeries | Missing |
|----------|--------|-------------|-----------------|---------|
| {CAT} | {wt}% | {n} | {n} | **{n}** |
| **TOTAL** | | **{n}** | **{n}** | **{n}** |
---
## Notes
- Aeries normalizes all assignments to 100 points; MOM uses variable points
- Note name discrepancies between systems
- Flag any typos (e.g., "Indvidual" vs "Individual")
- Dates sourced from MOM settings pages (`moasettings.php?cid=...&aid=...`)
- Dates shown as `—` if the settings fetch was skipped or unavailable
gradebookNum, aeriesBase, catMap, MOM assignments, Aeries assignments, matched, missingC:\Users\shuff\grade-cloning\temp\gb_compare_${gradebookNum}.jsonconst fs = require('fs');
fs.mkdirSync('C:\\Users\\shuff\\grade-cloning\\temp', { recursive: true });
const tempPath = `C:\\Users\\shuff\\grade-cloning\\temp\\gb_compare_${gradebookNum}.json`;
fs.writeFileSync(
tempPath,
JSON.stringify(
{
metadata: {
gradebookNum,
aeriesBase,
extractedAt: new Date().toISOString(),
},
catMap,
momAssignments: momAssignmentsWithDates ?? momAssignments,
aeriesAssignments,
matched,
missing,
},
null,
2
)
);
console.log('Temp file written: ' + tempPath);
console.log(' ' + missing.length + ' missing, ' + matched.length + ' matched');
Temp JSON contract notes:
missing array must contain full assignment objects with date fields when available:
{ name, pts, category, weight, assignedDate, dueDate }gb-new-assignment without re-scrapingtemp and gb_compare naming so downstream discovery keeps working| Problem | Action |
|---|---|
| No MOM tab found | Ask the user to open the MOM gradebook tab first. |
| No Aeries tab found | Ask the user to open the Aeries Gradebook page first. |
| Playwriter not active on one tab | Ask the user to click the Playwriter icon on that tab. |
#availshow missing | Wait for MOM to finish loading and verify the page is the gradebook. |
No span.cattothdr or th[data-pts] rows | Re-run the expand step and confirm MOM categories are visible. |
No th[data-an] rows | Verify the Aeries page is a gradebook page, not another view. |
| MOM settings fetch fails | Keep assignedDate and dueDate null and continue the comparison. |
| Zero plausible matches found | Confirm the two tabs are for the same course and inspect both extracted lists manually. |
| Mistake | Fix |
|---|---|
| Leaving MOM on the default filter | Set #availshow to 2 for All before extracting. |
| Forgetting to expand categories | Click every [Expand] link until none remain. |
Using th.textContent for MOM names | Use div.childNodes[0].textContent so [Settings][Isolate] is excluded. |
Using innerHTML for Aeries names | Use textContent.trim() so entities decode correctly. |
| Doing exact string matching | Use the number-anchored algorithm with a 0.4 threshold. |
| Treating Aeries 100-point normalization as a mismatch | Compare assignment presence, not raw point values. |
| Parsing settings URLs from encoded HTML | Use settingsLink.href, then URLSearchParams, to get aid and cid. |
| Reporting blank dates as real data | Keep JSON dates null and show — in the markdown report. |
| Clicking Aeries controls during comparison | Do not mutate anything; this skill is read-only only. |
| Element | Selector / Pattern | Notes |
|---|---|---|
| Show filter dropdown | #availshow | Set to 2 for All |
| Category header label | span.cattothdr | Text like GROUP 10% |
| Category CSS class | .cat1, .cat2, .cat3 | Class lives on parent th |
| Expand controls | a with text [Expand] | Click until no links remain |
| Assignment headers | th[data-pts] | One th per assignment |
| Clean assignment name | th.querySelector('div').childNodes[0].textContent | Excludes settings/isolate cruft |
| MOM points | th.getAttribute('data-pts') | Variable point values |
| Settings link | a[href*="moasettings"] | Use to get aid and cid |
| Element | Selector / Pattern | Notes |
|---|---|---|
| Assignment headers | th[data-an] | One th per assignment |
| Assignment number | th.getAttribute('data-an') | Aeries assignment ID/number |
| Assignment name | th.textContent.trim() | Decodes entities automatically |
missing vs matched) to the user