Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions chrome-extension/calendar/calendar.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,38 @@
display: flex;
justify-content: space-between;
align-items: center;
position: relative; /* Needed for tooltip positioning */
}

#gg_gcal_button:hover {
opacity: 90%;
}

.gg_gcal_tooltip {
visibility: hidden;
opacity: 0.7;
background-color: #5B0013;
box-shadow: #5B0013 0 0 14px;
color: #fff;
text-align: center;
border-radius: 4px;
padding: 4px 8px;
position: absolute;
left: 50%;
top: 40px;
transform: translateX(-50%);
z-index: 10000;
font-size: 13px;
transition: opacity 0.2s;
pointer-events: none;
white-space: nowrap;
}

#gg_gcal_button:hover .gg_gcal_tooltip {
visibility: visible;
opacity: 1;
}

#cover-spin {
position: fixed;
width: 100%;
Expand Down
1 change: 1 addition & 0 deletions chrome-extension/calendar/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const buttonTemplate = `
<button id="gg_gcal_button">
<div id="gg_icon"></div>
Add Classes to Google Calendar
<span class="gg_gcal_tooltip">You can also export to an .ics file and then import into calendar of your choice<br>Powered by Gopher Grades!</span>
</button>
</div>
`;
Expand Down
176 changes: 128 additions & 48 deletions chrome-extension/calendar/utils/scraper.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
/**
* # Course Data Scraper Utilities
*
* Some utilityish functions to scrape course data
* The two functions work, but this alone is not yet very useful
* Also the functions are still missing some features, (see commented-out lines in JSON objects)
*
* Main Functions:
* - weekToJSON: Scrapes meetings for a given week
* - generalClassInfo: Scrapes general info for a class
* - scraperSecondPass: Cross-references and enhances course/meeting info
* - scrapeASemester: Orchestrates scraping for an entire semester
*
* ORGANIZED: The file is restructured, with original comments retained where useful for context.
*/

// ============================
// Week-Level Scraping
// ============================

/**
* Scrapes a list of json objects containing all the meetings in a given week
* Json includes course name, number, date (a single date!), time, room, etc.
Expand All @@ -17,7 +31,7 @@ const weekToJSON = async (dateString = "today") => {

// appends the date info to our base url
let baseURL =
"https://www.myu.umn.edu/psp/psprd/EMPLOYEE/CAMP/s/WEBLIB_IS_DS.ISCRIPT1.FieldFormula.IScript_DrawSection?group=UM_SSS&section=UM_SSS_ACAD_SCHEDULE&pslnk=1&cmd=smartnav"; // the base url
"https://www.myu.umn.edu/psp/psprd/EMPLOYEE/CAMP/s/WEBLIB_IS_DS.ISCRIPT1.FieldFormula.IScript_DrawSection?group=UM_SSS&section=UM_SSS_ACAD_SCHEDULE&pslnk=1&cmd=smartnav";
let url = baseURL.concat("&effdt=", dateString);

// create a (queryable!) DOM element from the url
Expand All @@ -27,48 +41,107 @@ const weekToJSON = async (dateString = "today") => {
.then((r) => (el.innerHTML = r));

// parsing begins
var synchronousMeetings = el.querySelector(".myu_calendar"); // HTML div containing only the classes with set days and times
var synchronousMeetings = el.querySelector(".myu_calendar");
console.log("[GG DEBUG] synchronousMeetings:", synchronousMeetings);

if (synchronousMeetings == null) {
console.log(`[GG] encountered a week without meetings (${dateString}).`);
console.log(`[GG DEBUG] encountered a week without meetings (${dateString}).`);
return {
meetingObjects: [],
sundayDate: sundayThisWeek(parseDate(dateString, "yyyy-mm-dd")),
};
}
var meetingElements = synchronousMeetings.querySelectorAll(
".myu_calendar-class"
); // list of all the classes this week as HTML elems
const meetingObjects = []; // list of json objects holding meeting data

var meetingElements = synchronousMeetings.querySelectorAll(".myu_calendar-class");
const meetingObjects = [];

// Loop through each calendar class element
for (let meetingEl of meetingElements) {
console.log("[GG DEBUG] Found meetingEl:", meetingEl, meetingEl.innerText);

// Skip any placeholder for days with no classes
if (meetingEl.classList.contains("no-class")) {
// sometimes a meetingEl marks when there are no classes in a day?
console.log("[GG] encountered a no-class meetingElement. skipping...");
console.log("[GG DEBUG] encountered a no-class meetingElement. skipping...");
continue;
}

let classDetails = meetingEl
.querySelector(".myu_calendar-class-details")
.innerHTML.replace(/\n/g, "") // get rid of random newlines that are in there for some reason

// Defensive: Try to get class details element
let classDetailsEl = meetingEl.querySelector(".myu_calendar-class-details");
if (!classDetailsEl) {
console.warn("[GG DEBUG] No .myu_calendar-class-details found for:", meetingEl);
continue;
}
let classDetailsRaw = classDetailsEl.innerHTML;

// Clean up HTML to plain text
let classDetails = classDetailsRaw
.replace(/\n/g, "")
.replace(/<br>/g, "\n");
let courseTitleScrape = meetingEl.querySelector(
".myu_calendar-class-name"
).innerText;

meetingObjects.push({
term: meetingEl.getAttribute("data-strm").trim(), // {string} // in format `xyyx', where `yy` is the year and `xx` = `13` is spring, `19` is fall
courseNum: meetingEl.getAttribute("data-class-nbr").trim(), // {string}
date: parseDate(meetingEl.getAttribute("data-fulldate"), "yyyymmdd"), // {Date}
meetingType: classDetails.match(/^(Lecture)|(Discussion)|(Laboratory)$/m)[0], // {string} // may need updating if list is not exhaustive
timeRange: classDetails.match(/.*:.*/m)[0], // {string}
room: classDetails.match(/^ .* $/m)[0].trim(), // {string} // (room has leading and trailing space)
courseName: meetingEl
.querySelector(".myu_calendar-class-name-color-referencer")
.innerText.trim(), // {string}
institution: meetingEl.dataset.institution, // {string}
prettyName: courseTitleScrape.match(/(?<=\d\) ).*/)[0].trim(), // {string} e.g. "Adv Programming Principles"
sectionID: courseTitleScrape.match(/(?<=\()\d+(?=\))/)[0], // {string} e.g. "001" of "CSCI 2021 (001)"
}); // {MeetingObject} spec

// Defensive: Try to get course title element
let courseTitleEl = meetingEl.querySelector(".myu_calendar-class-name");
if (!courseTitleEl) {
console.warn("[GG DEBUG] No .myu_calendar-class-name found for:", meetingEl);
continue;
}
let courseTitleScrape = courseTitleEl.innerText;

// Defensive: Try to get color referencer
let courseNameEl = meetingEl.querySelector(".myu_calendar-class-name-color-referencer");
let courseName = courseNameEl ? courseNameEl.innerText.trim() : null;

// Extract with regex, with fallback if match fails
let meetingTypeMatch = classDetails.match(/^(Lecture|Discussion|Laboratory)/m);
let timeRangeMatch = classDetails.match(/.*:.*/m);
let roomMatch = classDetails.match(/(\n\s*.+\s*\n)/m); // More robust for room line

let prettyNameMatch = courseTitleScrape.match(/(?<=\d\) ).*/);
let sectionIDMatch = courseTitleScrape.match(/(?<=\()\d+(?=\))/);

// Print extracted pieces
// console.log("[GG DEBUG] meetingTypeMatch:", meetingTypeMatch);
// console.log("[GG DEBUG] timeRangeMatch:", timeRangeMatch);
// console.log("[GG DEBUG] roomMatch:", roomMatch);
// console.log("[GG DEBUG] prettyNameMatch:", prettyNameMatch);
// console.log("[GG DEBUG] sectionIDMatch:", sectionIDMatch);

// Defensive: Data attributes
let term = meetingEl.getAttribute("data-strm");
let courseNum = meetingEl.getAttribute("data-class-nbr");
let dateRaw = meetingEl.getAttribute("data-fulldate");
let institution = meetingEl.getAttribute("data-institution");

// Defensive: Only add if all required data is present
if (
term && courseNum && dateRaw &&
meetingTypeMatch && timeRangeMatch && roomMatch &&
courseName && institution && prettyNameMatch && sectionIDMatch
) {
// Parse date string as needed
let date = parseDate(dateRaw, "yyyymmdd");

meetingObjects.push({
term: term.trim(),
courseNum: courseNum.trim(),
date: date,
meetingType: meetingTypeMatch[0],
timeRange: timeRangeMatch[0],
room: roomMatch[1].trim(),
courseName: courseName,
institution: institution,
prettyName: prettyNameMatch[0].trim(),
sectionID: sectionIDMatch[0],
});

console.log("[GG DEBUG] Added meetingObject:", meetingObjects[meetingObjects.length - 1]);
} else {
console.warn("[GG DEBUG] Skipping meeting due to missing data:", {
term, courseNum, dateRaw, meetingTypeMatch, timeRangeMatch, roomMatch, courseName, institution, prettyNameMatch, sectionIDMatch
});
}
}

console.log("[GG DEBUG] Final meetingObjects array:", meetingObjects);

weekObject = {
meetingObjects: meetingObjects,
Expand All @@ -77,6 +150,10 @@ const weekToJSON = async (dateString = "today") => {
return weekObject;
};

// ============================
// General Class Info Scraping
// ============================

/**
* Scrapes general info on a class: session start, days of week, meeting times, location, etc
* @param {string} term
Expand Down Expand Up @@ -111,12 +188,12 @@ const generalClassInfo = async (term, courseNum, institution = "UMNTC") => {
synchronousRow = row;
synchronousRowCounter++;
} else {
console.log("[GG] found asynchronous meeting in `generalClassInfo`, ignoring...");
console.log("[GG DEBUG] found asynchronous meeting in `generalClassInfo`, ignoring...");
}
}

if (synchronousRowCounter != 1) {
console.log(`[GG]!!!!!!!!!!!!!!! ALERT, ${synchronousRowCounter} synchronous meeting found in generalClassInfo() for url ${url}`);
console.log(`[GG DEBUG]!!!!!!!!!!!!!!! ALERT, ${synchronousRowCounter} synchronous meeting found in generalClassInfo() for url ${url}`);
} // if this runs, that means we have a big headache and rewrite ahead of us. pls no

// dateRange parsing
Expand Down Expand Up @@ -148,6 +225,10 @@ const generalClassInfo = async (term, courseNum, institution = "UMNTC") => {
}; // {CourseObject} spec
};

// ============================
// Data Enhancement and Cross-Reference
// ============================

/**
* Enhances `coursesInfo` by cross-referencing between it and `weeks`.
* Populates `meetingType`, `courseName` (but not `address` yet)
Expand All @@ -170,6 +251,7 @@ const scraperSecondPass = (weeks, coursesInfo) => {
course.sectionID = meeting.sectionID;
course.prettyName = meeting.prettyName;
continue courseloop; // hah, this is fun!
//that's not such fun actually
}
}
}
Expand All @@ -189,23 +271,18 @@ const scraperSecondPass = (weeks, coursesInfo) => {
*/
const timeRangeRepr = (timeRangeString, format) => {
let ARBITRARY_DATE = new Date();
let timeRange;
if (format == "week") {
// "hh:mm - hh:mm pm" (first am/pm is ommitted)
timeRange = getTimes(timeRangeString, ARBITRARY_DATE); // long string that is dumb. we will cut it down >:)
timeRange = getTimes(timeRangeString, ARBITRARY_DATE);
} else if (format == "coursesInfo") {
// "hh:mm pm - hh:mm pm"
timeRange = recurringGetTimes(timeRangeString, ARBITRARY_DATE); // long string that is dumb
timeRange = recurringGetTimes(timeRangeString, ARBITRARY_DATE);
} else {
throw new Error(
`timeRangeRepr() was passed unrecognized format ${format}`
);
throw new Error(`timeRangeRepr() was passed unrecognized format ${format}`);
}
return timeRange.map((s) => s.slice(9, 13)).join("-"); // crop off extraneous info
};

// ###################################
// First, set up model week hash table
// ###################################
let modelWeekHT = {}; // empty json object (hash table)
for (day = 0; day < 7; day++) {
// for day in a week
Expand All @@ -227,9 +304,7 @@ const scraperSecondPass = (weeks, coursesInfo) => {

let additionalMeetings = [];

// ###############################
// Main loop through all the weeks
// ###############################
for (let week of weeks) {
// Create a hash table for the actual week as well
let thisWeekHT = {};
Expand All @@ -254,8 +329,8 @@ const scraperSecondPass = (weeks, coursesInfo) => {
excludedDate.setDate(excludedDate.getDate() + dayOfWeek); // then shift the date to the correct day of week
// console.log(excludedDate)

console.log(`[GG] Excluded meeting found! hash: ${hash}`);
console.log(`[GG] date: ${excludedDate}`)
console.log(`[GG DEBUG] Excluded meeting found! hash: ${hash}`);
console.log(`[GG DEBUG] date: ${excludedDate}`)
modelWeekHT[hash].course.excludedDates.push(excludedDate);
}
}
Expand All @@ -274,6 +349,7 @@ const scraperSecondPass = (weeks, coursesInfo) => {
}
}

// console.log("[GG DEBUG] modelWeek: \n")
// console.log(modelWeek)
return additionalMeetings;
};
Expand All @@ -285,14 +361,18 @@ const scraperSecondPass = (weeks, coursesInfo) => {
return additionalMeetings;
};

// ============================
// Semester-Level Scraping
// ============================

// BEGIN ZONE OF EXTRA JANK

// Scraping up all meeting times, including catching when classes are canceled for holidays
// General game plan:
// 1. pick a sample week (which week best to pick?), then grab all the course numbers in it
// 2. Then get the general course info for each of those course numbers, store it somewhere
// 3. Then take one of the `dateRange`s (they're all the same) and scrape through the whole thing to find instances of when a class should appear but it doesn't. store this somehow
console.log("[GG] scraper.js runs!");
console.log("[GG DEBUG] scraper.js runs!");

/**
* Given a date, returns info on each course meeting in the semester containing that date. Also returns generally-true info about the courses in the semester
Expand Down Expand Up @@ -347,4 +427,4 @@ const scrapeASemester = async (sampleDateString = "today") => {
coursesInfo: coursesInfo,
additionalMeetings: additionalMeetings,
}; // {Array<MeetingObject>}
};
};
2 changes: 1 addition & 1 deletion chrome-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "Gopher Grades - Past grades for UMN classes!",
"description": "Now you can view directly from the course catalog!",
"version": "2.1.0",
"version": "2.2.0",
"manifest_version": 3,
"background": {
"service_worker": "background.js"
Expand Down