Last active
March 25, 2026 18:52
-
-
Save benmarwick/b9651a57132513f7215fc7526fc863ab to your computer and use it in GitHub Desktop.
UW Anthropology MyGrad and Time Schedule Data Extractor tampermonkey scripts & bookmarklets
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| javascript:(function(){'use strict';const CONFIG={llmPrompt:"You are an experienced academic advisor who cares deeply about supporting students in their graduate studies. You are highly-detail oriented and laser-focused on giving correct advice. Your tone is warm and supportive. You write in the third person point of view, never first or second person point of view. You never make anything up. You always quote exact verbatim text from a specific University of Washington websites to support your decisions. Do not paraphrase or infer. You always include clickable links to the websites that you quote from. Do not quote anything unless you fetch it directly from the URLs I have given you. Quote only the relevant requirement sections. If you cannot fetch it, say so. You do not read webpages from Departments other than the Anthropology Department at the University of Washington. You do not read webpages from other universities. If you see 'PRE‑DOCTOR (ANTH‑50‑3‑0)', understand this as the student is enrolled in a PhD program. Look at the student's transcript to determine which program they are in:\n\n ARCHY = Archaeology\n BIO A = Biological Anthropology\n ANTH = Sociocultural\n\nIf the data show the student has an MA, this fulfils the requirement to have an MA. Look at the year of the first class on the transcript, this is the year they started their PhD. Include that start year in your report, and use that start year to evaluate the student's rate of progress towards their requirements. Your task is evaluate this student's progress against the relevant UW PhD requirements using the provided data. Summarize the student's completed milestones, current standing, and what they need to do next. Here are the URLs with the graduate degree requirements, you only access the one page that relevant to this student, not all three pages:\n\nhttps://anthropology.washington.edu/phd-anthropology-sociocultural-anthropology\n\nhttps://anthropology.washington.edu/phd-anthropology-archaeology\n\nhttps://anthropology.washington.edu/phd-anthropology-biological-anthropology\n\nYou also study the UW Graduate School Policy and Guidance at\n\nhttps://grad.uw.edu/policy_audience/doctoral-students/\n\nhttps://grad.uw.edu/policy_audience/doctoral-students/page/2\n\nhttps://grad.uw.edu/policy_audience/doctoral-students/page/3\n\nwhere you can get information about the ten year rule. You present your findings in a table with one row each for:\n\nRequired coursework completed\nMA earned\nCommittee formed\nGeneral exam passed\nDissertation credits recorded\n\nYou summarise any issues, red flags, and concerns in a second table. In both tables use green, orange, and red circle emojis to mark completion (green), emerging/approaching/potential issues (orange), and overdue milestones (red). You close with a one-paragraph narrative about the next steps the student needs to take. You use the student's name frequently in your report.\n\n",compactJSON:true,dedupeAcrossPages:true,removeEmptyFields:true,shortKeys:true,removeTableTextFromLines:true,uwBoilerplatePatterns:[/skip to main content/i,/university of washington/i,/\bmygrad\b/i,/\b(home|help|logout|edit|print)\b/i,/add committee|update degree|waive dissertation|reinstate dissertation|reset dissertation/i,/student detail|committee|transcript|degree progress|student requests/i,/©\s*\d{4}.*university/i,/accessibility|privacy|terms of use/i,/view applicants|view grad students|view faculty|view admin/i,/main page|end session|return to student details/i,/^\s*[\|•\-–—]\s*$/,/^\s*$/]};const studentId=new URLSearchParams(window.location.search).get('id');if(!studentId){console.warn("[UW Extractor] No student ID found in URL. Script will not load.");return;}let globalSeenLines=new Set();const btn=document.createElement('button');btn.textContent="Click here to begin collecting student data";btn.title="Copies minified, deduplicated JSON to clipboard";const styleNormal="position:fixed; top:15px; left:50%; transform:translateX(-50%); z-index:10000; padding:10px 14px; background:#4b2e83; color:#fff; border:3px solid #b7a57a; border-radius:6px; cursor:pointer; font-weight:600; box-shadow:0 4px 8px rgba(0,0,0,0.35); font-size:13px; line-height:1.2; max-width:350px; text-align:center; transition: all 0.2s ease;";btn.style.cssText=styleNormal;document.body.appendChild(btn);btn.addEventListener('mouseenter',()=>{if(!btn.disabled)btn.style.background="#5b3b9c";});btn.addEventListener('mouseleave',()=>{if(!btn.disabled)btn.style.background="#4b2e83";});function pruneEmpty(obj){if(obj===null||obj===undefined)return null;if(typeof obj==='string')return obj.trim()===''?null:obj.trim();if(Array.isArray(obj)){const cleaned=obj.map(pruneEmpty).filter(v=>v!==null&&v!==undefined);return cleaned.length>0?cleaned:null;}if(typeof obj==='object'){const cleaned={};for(const[k,v] of Object.entries(obj)){const val=pruneEmpty(v);if(val!==null&&val!==undefined)cleaned[k]=val;}return Object.keys(cleaned).length>0?cleaned:null;}return obj;}function getTableTitle(table){const cap=table.querySelector('caption');if(cap?.innerText.trim())return cap.innerText.trim();let el=table.previousElementSibling;while(el){if(el.matches?.('h1,h2,h3,h4,h5,strong,[role="heading"]')){const txt=el.innerText.trim();if(txt&&txt.length<100)return txt;}el=el.previousElementSibling;}return null;}function parseAllTables(doc){const out=[];const tables=Array.from(doc.querySelectorAll('table'));tables.forEach((table,tIndex)=>{const trs=Array.from(table.querySelectorAll('tr'));if(trs.length<2)return;let headerIdx=0;while(headerIdx<trs.length&&Array.from(trs[headerIdx].cells).every(c=>!c.innerText.trim())){headerIdx++;}if(headerIdx>=trs.length)return;const headerCells=Array.from(trs[headerIdx].querySelectorAll('th,td'));const headers=headerCells.map((cell,i)=>{const txt=cell.innerText.replace(/\s+/g,' ').trim();return txt||%60C${i+1}%60;});const rows=[];for(let i=headerIdx+1;i<trs.length;i++){const cells=Array.from(trs[i].querySelectorAll('td,th'));if(!cells.length)continue;const rowObj={};let hasData=false;cells.forEach((cell,j)=>{const val=cell.innerText.replace(/\s+/g,' ').trim();if(!val||val==='|')return;if(CONFIG.uwBoilerplatePatterns.some(p=>p.test(val)))return;hasData=true;let key=headers[j]||%60C${j+1}%60;if(CONFIG.uwBoilerplatePatterns.some(p=>p.test(key))||key==='|'){key=%60C${j+1}%60;}rowObj[key]=val;});if(hasData)rows.push(rowObj);}if(rows.length>0){out.push({title:getTableTitle(table)||%60Table_${tIndex+1}%60,rows:rows});}});return out;}function getCleanPageTextLines(doc,tableTexts=new Set()){const clone=doc.body.cloneNode(true);clone.querySelectorAll('script,style,noscript,nav,footer,header,button,table,iframe,[role="navigation"],[role="banner"],[role="contentinfo"]').forEach(n=>n.remove());clone.querySelectorAll('a,input,select,label,[onclick]').forEach(el=>{const t=(el.innerText||el.value||'').trim().toLowerCase();if(!t){el.remove();return;}if(CONFIG.uwBoilerplatePatterns.some(p=>p.test(t))){el.remove();return;}if(t.length<4&&/^[a-z]+$/i.test(t)){el.remove();}});let text=(clone.innerText||'').replace(/\u00A0/g,' ');const seen=new Set();const lines=[];for(const raw of text.split('\n')){let line=raw.replace(/\s+/g,' ').trim();if(!line)continue;if(CONFIG.uwBoilerplatePatterns.some(p=>p.test(line)))continue;if(seen.has(line))continue;if(CONFIG.removeTableTextFromLines&&tableTexts.has(line))continue;if(CONFIG.dedupeAcrossPages&&globalSeenLines.has(line))continue;seen.add(line);if(CONFIG.dedupeAcrossPages)globalSeenLines.add(line);lines.push(line);}return lines;}function collectTableTexts(tables){const texts=new Set();tables.forEach(t=>{t.rows?.forEach(row=>{Object.values(row).forEach(v=>{if(v&&typeof v==='string'){const clean=v.replace(/\s+/g,' ').trim();if(clean)texts.add(clean);}});});});return texts;}const fetchRenderedData=(url,key)=>new Promise((resolve)=>{console.log(%60[UW Extractor] Fetching background page: ${key} (${url})%60);const iframe=document.createElement('iframe');iframe.style.cssText="display:none;position:fixed;left:-9999px;";document.body.appendChild(iframe);const cleanup=()=>{try{iframe.remove();}catch(e){}};iframe.onload=()=>{setTimeout(()=>{try{const doc=iframe.contentDocument||iframe.contentWindow.document;const tables=parseAllTables(doc);const tableTexts=collectTableTexts(tables);const textLines=getCleanPageTextLines(doc,tableTexts);console.log(%60[UW Extractor] Successfully parsed[${key}]. Found ${tables.length} tables and ${textLines.length} unique text lines.%60);resolve({textLines,tables});}catch(e){console.error(%60[UW Extractor] Extraction error on [${key}]:%60,e);resolve({textLines:["[ERR]"],tables:[]});}finally{cleanup();}},2200);};iframe.onerror=()=>{console.error(%60[UW Extractor] Failed to load[${key}]%60);cleanup();resolve({textLines:["[LOAD_ERR]"],tables:[]});};iframe.src=url;});btn.addEventListener('click',async()=>{console.log(%60\n[UW Extractor] --- STARTING EXTRACTION FOR STUDENT ${studentId} ---%60);globalSeenLines.clear();const originalText="Click here to begin collecting student data";btn.textContent="⏳ Gathering data (0/4 pages)...";btn.style.background="#333";btn.disabled=true;try{const base="https://webappssecure.grad.uw.edu/mgp-dept.stu.detail";const reqBase="https://webappssecure.grad.uw.edu/mgp-dept/stu";let detailUrl=%60${base}/home/studentdetail?id=${studentId}%60;let committeeUrl=%60${base}/committee/index?id=${studentId}%60;let transcriptUrl=%60${base}/home/transcript?id=${studentId}%60;let requestsUrl=%60${reqBase}/request/threshold.aspx?id=${studentId}&ORG=14&REDIRECT=../list_student_requests.aspx?id=${studentId}%60;const pages=[{key:"detail",url:detailUrl},{key:"committee",url:committeeUrl},{key:"transcript",url:transcriptUrl},{key:"requests",url:requestsUrl}];let pagesCompleted=0;const results=await Promise.all(pages.map(async(p)=>{const data=await fetchRenderedData(p.url,p.key);pagesCompleted++;btn.textContent=%60⏳ Gathering data (${pagesCompleted}/${pages.length} pages)...%60;return{key:p.key,...data};}));console.log("[UW Extractor] Building final JSON payload...");const payload={id:studentId,ts:new Date().toISOString(),d:{}};results.forEach(r=>{let section={};if(CONFIG.shortKeys){section.t=r.textLines;section.tb=r.tables;}else{section.textLines=r.textLines;section.tables=r.tables;}payload.d[r.key]=section;});const finalPayload=CONFIG.removeEmptyFields?pruneEmpty(payload):payload;const json=CONFIG.compactJSON?JSON.stringify(finalPayload):JSON.stringify(finalPayload,null,2);const finalClipboardText=CONFIG.llmPrompt+json;await navigator.clipboard.writeText(finalClipboardText);console.log(%60[UW Extractor] --- SUCCESS --- Payload copied to clipboard. Length: ${finalClipboardText.length} characters.%60);btn.textContent="✅ Student data copied!";btn.style.background="#2e8b57";btn.style.borderColor="#1e5c3a";setTimeout(()=>{btn.textContent=originalText;btn.style.background="#4b2e83";btn.style.borderColor="#b7a57a";btn.disabled=false;},3500);}catch(err){console.error("[UW Extractor] Fatal error during extraction:",err);btn.textContent="❌ Error (check browser console)";btn.style.background="#c00";btn.style.borderColor="#800";btn.disabled=false;}});document.addEventListener('keydown',(e)=>{if(e.ctrlKey&&e.altKey&&e.key.toLowerCase()==='c'){e.preventDefault();console.log("[UW Extractor] Keyboard shortcut triggered.");btn.click();}});})(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name UW MyGrad Student Data Extractor (LLM Ready) | |
| // @namespace http://tampermonkey.net/ | |
| // @version 6.2 | |
| // @description Copies compact, deduplicated JSON with progress UI, console logs, and LLM prompt prefix | |
| // @match https://webappssecure.grad.uw.edu/mgp-dept.stu.detail/home/StudentDetail?id=* | |
| // @match https://webappssecure.grad.uw.edu/mgp-dept.stu.detail/home/studentdetail?id=* | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ========== CONFIG ========== | |
| const CONFIG = { | |
| // Text prepended to the final clipboard output to instruct the LLM | |
| llmPrompt: `You are an experienced academic advisor who cares deeply about supporting students in their graduate studies. You are highly-detail oriented and laser-focused on giving correct advice. Your tone is warm and supportive. You write in the third person point of view, never first or second person point of view. You never make anything up. You always quote exact verbatim text from a specific University of Washington websites to support your decisions. Do not paraphrase or infer. You always include clickable links to the websites that you quote from. Do not quote anything unless you fetch it directly from the URLs I have given you. Quote only the relevant requirement sections. If you cannot fetch it, say so. You do not read webpages from Departments other than the Anthropology Department at the University of Washington. You do not read webpages from other universities. If you see 'PRE‑DOCTOR (ANTH‑50‑3‑0)', understand this as the student is enrolled in a PhD program. Look at the student's transcript to determine which program they are in: | |
| ARCHY = Archaeology | |
| BIO A = Biological Anthropology | |
| ANTH = Sociocultural | |
| If the data show the student has an MA, this fulfils the requirement to have an MA. Look at the year of the first class on the transcript, this is the year they started their PhD. Include that start year in your report, and use that start year to evaluate the student's rate of progress towards their requirements. Your task is evaluate this student's progress against the relevant UW PhD requirements using the provided data. Summarize the student's completed milestones, current standing, and what they need to do next. Here are the URLs with the graduate degree requirements, you only access the one page that relevant to this student, not all three pages: | |
| https://anthropology.washington.edu/phd-anthropology-sociocultural-anthropology | |
| https://anthropology.washington.edu/phd-anthropology-archaeology | |
| https://anthropology.washington.edu/phd-anthropology-biological-anthropology | |
| You also study the UW Graduate School Policy and Guidance at | |
| https://grad.uw.edu/policy_audience/doctoral-students/ | |
| https://grad.uw.edu/policy_audience/doctoral-students/page/2 | |
| https://grad.uw.edu/policy_audience/doctoral-students/page/3 | |
| where you can get information about the ten year rule. You present your findings in a table with one row each for: | |
| Required coursework completed | |
| MA earned | |
| Committee formed | |
| General exam passed | |
| Dissertation credits recorded | |
| You summarise any issues, red flags, and concerns in a second table. In both tables use green, orange, and red circle emojis to mark completion (green), emerging/approaching/potential issues (orange), and overdue milestones (red). You close with a one-paragraph narrative about the next steps the student needs to take. You use the student's name frequently in your report. | |
| \n\n`, | |
| compactJSON: true, // Minify JSON (no whitespace) | |
| dedupeAcrossPages: true, // Remove text lines that appear on multiple pages | |
| removeEmptyFields: true, // Strip null/empty/whitespace-only values | |
| shortKeys: true, // Use short keys: t=text, tb=tables, ts=timestamp | |
| removeTableTextFromLines: true, // Prevent table cell values from appearing in textLines | |
| uwBoilerplatePatterns:[ // Regex patterns to strip UW-specific noise | |
| /skip to main content/i, | |
| /university of washington/i, | |
| /\bmygrad\b/i, | |
| /\b(home|help|logout|edit|print)\b/i, | |
| /add committee|update degree|waive dissertation|reinstate dissertation|reset dissertation/i, | |
| /student detail|committee|transcript|degree progress|student requests/i, | |
| /©\s*\d{4}.*university/i, | |
| /accessibility|privacy|terms of use/i, | |
| // Added patterns to exclude old ASP.NET menu navigation noise: | |
| /view applicants|view grad students|view faculty|view admin/i, | |
| /main page|end session|return to student details/i, | |
| /^\s*[\|•\-–—]\s*$/, // Separator lines | |
| /^\s*$/ // Blank lines | |
| ] | |
| }; | |
| const studentId = new URLSearchParams(window.location.search).get('id'); | |
| if (!studentId) { | |
| console.warn("[UW Extractor] No student ID found in URL. Script will not load."); | |
| return; | |
| } | |
| // Global dedupe set (cleared on every fresh run) | |
| let globalSeenLines = new Set(); | |
| // ========== UI BUTTON ========== | |
| const btn = document.createElement('button'); | |
| btn.textContent = "Click here to begin collecting student data"; | |
| btn.title = "Copies minified, deduplicated JSON to clipboard"; | |
| // Base styles - Centered at the top to avoid covering links/text | |
| const styleNormal = "position:fixed; top:15px; left:50%; transform:translateX(-50%); z-index:10000; padding:10px 14px; background:#4b2e83; color:#fff; border:3px solid #b7a57a; border-radius:6px; cursor:pointer; font-weight:600; box-shadow:0 4px 8px rgba(0,0,0,0.35); font-size:13px; line-height:1.2; max-width:350px; text-align:center; transition: all 0.2s ease;"; | |
| btn.style.cssText = styleNormal; | |
| document.body.appendChild(btn); | |
| // Hover visual feedback | |
| btn.addEventListener('mouseenter', () => { if (!btn.disabled) btn.style.background = "#5b3b9c"; }); | |
| btn.addEventListener('mouseleave', () => { if (!btn.disabled) btn.style.background = "#4b2e83"; }); | |
| // ========== HELPERS ========== | |
| // Recursively remove null, empty string, empty objects/arrays | |
| function pruneEmpty(obj) { | |
| if (obj === null || obj === undefined) return null; | |
| if (typeof obj === 'string') return obj.trim() === '' ? null : obj.trim(); | |
| if (Array.isArray(obj)) { | |
| const cleaned = obj.map(pruneEmpty).filter(v => v !== null && v !== undefined); | |
| return cleaned.length > 0 ? cleaned : null; | |
| } | |
| if (typeof obj === 'object') { | |
| const cleaned = {}; | |
| for (const[k, v] of Object.entries(obj)) { | |
| const val = pruneEmpty(v); | |
| if (val !== null && val !== undefined) cleaned[k] = val; | |
| } | |
| return Object.keys(cleaned).length > 0 ? cleaned : null; | |
| } | |
| return obj; | |
| } | |
| function getTableTitle(table) { | |
| const cap = table.querySelector('caption'); | |
| if (cap?.innerText.trim()) return cap.innerText.trim(); | |
| let el = table.previousElementSibling; | |
| while (el) { | |
| if (el.matches?.('h1,h2,h3,h4,h5,strong,[role="heading"]')) { | |
| const txt = el.innerText.trim(); | |
| if (txt && txt.length < 100) return txt; | |
| } | |
| el = el.previousElementSibling; | |
| } | |
| return null; | |
| } | |
| function parseAllTables(doc) { | |
| const out =[]; | |
| const tables = Array.from(doc.querySelectorAll('table')); | |
| tables.forEach((table, tIndex) => { | |
| const trs = Array.from(table.querySelectorAll('tr')); | |
| if (trs.length < 2) return; | |
| let headerIdx = 0; | |
| while (headerIdx < trs.length && Array.from(trs[headerIdx].cells).every(c => !c.innerText.trim())) { | |
| headerIdx++; | |
| } | |
| if (headerIdx >= trs.length) return; | |
| const headerCells = Array.from(trs[headerIdx].querySelectorAll('th,td')); | |
| const headers = headerCells.map((cell, i) => { | |
| const txt = cell.innerText.replace(/\s+/g, ' ').trim(); | |
| return txt || `C${i+1}`; | |
| }); | |
| const rows =[]; | |
| for (let i = headerIdx + 1; i < trs.length; i++) { | |
| const cells = Array.from(trs[i].querySelectorAll('td,th')); | |
| if (!cells.length) continue; | |
| const rowObj = {}; | |
| let hasData = false; | |
| cells.forEach((cell, j) => { | |
| const val = cell.innerText.replace(/\s+/g, ' ').trim(); | |
| // Exclude pure empty strings, lone pipes, and boilerplate menu items | |
| if (!val || val === '|') return; | |
| if (CONFIG.uwBoilerplatePatterns.some(p => p.test(val))) return; | |
| hasData = true; | |
| let key = headers[j] || `C${j+1}`; | |
| // Scrub the header if it accidentally captured a layout menu item | |
| if (CONFIG.uwBoilerplatePatterns.some(p => p.test(key)) || key === '|') { | |
| key = `C${j+1}`; | |
| } | |
| rowObj[key] = val; | |
| }); | |
| if (hasData) rows.push(rowObj); | |
| } | |
| if (rows.length > 0) { | |
| out.push({ | |
| title: getTableTitle(table) || `Table_${tIndex+1}`, | |
| rows: rows | |
| }); | |
| } | |
| }); | |
| return out; | |
| } | |
| function getCleanPageTextLines(doc, tableTexts = new Set()) { | |
| const clone = doc.body.cloneNode(true); | |
| clone.querySelectorAll('script,style,noscript,nav,footer,header,button,table,iframe,[role="navigation"],[role="banner"],[role="contentinfo"]').forEach(n => n.remove()); | |
| clone.querySelectorAll('a,input,select,label,[onclick]').forEach(el => { | |
| const t = (el.innerText || el.value || '').trim().toLowerCase(); | |
| if (!t) { el.remove(); return; } | |
| if (CONFIG.uwBoilerplatePatterns.some(p => p.test(t))) { el.remove(); return; } | |
| if (t.length < 4 && /^[a-z]+$/i.test(t)) { el.remove(); } | |
| }); | |
| let text = (clone.innerText || '').replace(/\u00A0/g, ' '); | |
| const seen = new Set(); | |
| const lines =[]; | |
| for (const raw of text.split('\n')) { | |
| let line = raw.replace(/\s+/g, ' ').trim(); | |
| if (!line) continue; | |
| if (CONFIG.uwBoilerplatePatterns.some(p => p.test(line))) continue; | |
| if (seen.has(line)) continue; | |
| if (CONFIG.removeTableTextFromLines && tableTexts.has(line)) continue; | |
| if (CONFIG.dedupeAcrossPages && globalSeenLines.has(line)) continue; | |
| seen.add(line); | |
| if (CONFIG.dedupeAcrossPages) globalSeenLines.add(line); | |
| lines.push(line); | |
| } | |
| return lines; | |
| } | |
| function collectTableTexts(tables) { | |
| const texts = new Set(); | |
| tables.forEach(t => { | |
| t.rows?.forEach(row => { | |
| Object.values(row).forEach(v => { | |
| if (v && typeof v === 'string') { | |
| const clean = v.replace(/\s+/g, ' ').trim(); | |
| if (clean) texts.add(clean); | |
| } | |
| }); | |
| }); | |
| }); | |
| return texts; | |
| } | |
| const fetchRenderedData = (url, key) => new Promise((resolve) => { | |
| console.log(`[UW Extractor] Fetching background page: ${key} (${url})`); | |
| const iframe = document.createElement('iframe'); | |
| iframe.style.cssText = "display:none;position:fixed;left:-9999px;"; | |
| document.body.appendChild(iframe); | |
| const cleanup = () => { try { iframe.remove(); } catch(e) {} }; | |
| iframe.onload = () => { | |
| setTimeout(() => { | |
| try { | |
| const doc = iframe.contentDocument || iframe.contentWindow.document; | |
| const tables = parseAllTables(doc); | |
| const tableTexts = collectTableTexts(tables); | |
| const textLines = getCleanPageTextLines(doc, tableTexts); | |
| console.log(`[UW Extractor] Successfully parsed[${key}]. Found ${tables.length} tables and ${textLines.length} unique text lines.`); | |
| resolve({ textLines, tables }); | |
| } catch (e) { | |
| console.error(`[UW Extractor] Extraction error on [${key}]:`, e); | |
| resolve({ textLines: ["[ERR]"], tables:[] }); | |
| } finally { | |
| cleanup(); | |
| } | |
| }, 2200); | |
| }; | |
| iframe.onerror = () => { | |
| console.error(`[UW Extractor] Failed to load[${key}]`); | |
| cleanup(); | |
| resolve({ textLines: ["[LOAD_ERR]"], tables:[] }); | |
| }; | |
| iframe.src = url; | |
| }); | |
| // ========== MAIN CLICK HANDLER ========== | |
| btn.addEventListener('click', async () => { | |
| console.log(`\n[UW Extractor] --- STARTING EXTRACTION FOR STUDENT ${studentId} ---`); | |
| globalSeenLines.clear(); // Reset dedupe tracker | |
| const originalText = "Click here to begin collecting student data"; | |
| // Progress text will dynamically update out of 4 pages | |
| btn.textContent = "⏳ Gathering data (0/4 pages)..."; | |
| btn.style.background = "#333"; | |
| btn.disabled = true; | |
| try { | |
| const base = "https://webappssecure.grad.uw.edu/mgp-dept.stu.detail"; | |
| const reqBase = "https://webappssecure.grad.uw.edu/mgp-dept/stu"; | |
| // Default fallback URLs | |
| let detailUrl = `${base}/home/studentdetail?id=${studentId}`; | |
| let committeeUrl = `${base}/committee/index?id=${studentId}`; | |
| let transcriptUrl = `${base}/home/transcript?id=${studentId}`; | |
| let requestsUrl = `${reqBase}/request/threshold.aspx?id=${studentId}&ORG=14&REDIRECT=../list_student_requests.aspx?id=${studentId}`; | |
| const pages =[ | |
| { key: "detail", url: detailUrl }, | |
| { key: "committee", url: committeeUrl }, | |
| { key: "transcript", url: transcriptUrl }, | |
| { key: "requests", url: requestsUrl } | |
| ]; | |
| let pagesCompleted = 0; | |
| // Fetch concurrently, but update progress UI as each finishes | |
| const results = await Promise.all( | |
| pages.map(async (p) => { | |
| const data = await fetchRenderedData(p.url, p.key); | |
| pagesCompleted++; | |
| btn.textContent = `⏳ Gathering data (${pagesCompleted}/${pages.length} pages)...`; | |
| return { key: p.key, ...data }; | |
| }) | |
| ); | |
| console.log("[UW Extractor] Building final JSON payload..."); | |
| const payload = { | |
| id: studentId, | |
| ts: new Date().toISOString(), | |
| d: {} | |
| }; | |
| results.forEach(r => { | |
| let section = {}; | |
| if (CONFIG.shortKeys) { | |
| section.t = r.textLines; | |
| section.tb = r.tables; | |
| } else { | |
| section.textLines = r.textLines; | |
| section.tables = r.tables; | |
| } | |
| payload.d[r.key] = section; | |
| }); | |
| const finalPayload = CONFIG.removeEmptyFields ? pruneEmpty(payload) : payload; | |
| const json = CONFIG.compactJSON | |
| ? JSON.stringify(finalPayload) | |
| : JSON.stringify(finalPayload, null, 2); | |
| // Prepend LLM Prompt | |
| const finalClipboardText = CONFIG.llmPrompt + json; | |
| await navigator.clipboard.writeText(finalClipboardText); | |
| console.log(`[UW Extractor] --- SUCCESS --- Payload copied to clipboard. Length: ${finalClipboardText.length} characters.`); | |
| // Success visual feedback | |
| btn.textContent = "✅ Student data copied!"; | |
| btn.style.background = "#2e8b57"; | |
| btn.style.borderColor = "#1e5c3a"; | |
| setTimeout(() => { | |
| btn.textContent = originalText; | |
| btn.style.background = "#4b2e83"; | |
| btn.style.borderColor = "#b7a57a"; | |
| btn.disabled = false; | |
| }, 3500); | |
| } catch (err) { | |
| console.error("[UW Extractor] Fatal error during extraction:", err); | |
| btn.textContent = "❌ Error (check browser console)"; | |
| btn.style.background = "#c00"; | |
| btn.style.borderColor = "#800"; | |
| btn.disabled = false; | |
| } | |
| }); | |
| // Keyboard shortcut (Ctrl+Alt+C) | |
| document.addEventListener('keydown', (e) => { | |
| if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'c') { | |
| e.preventDefault(); | |
| console.log("[UW Extractor] Keyboard shortcut triggered."); | |
| btn.click(); | |
| } | |
| }); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name UW MyGrad PhD Exception Scanner (Accurate, 10s Delay, Debug Logs) | |
| // @namespace http://tampermonkey.net/ | |
| // @version 5.0 | |
| // @description Scans student list for time extensions, marks Pass/Fail, and logs detailed debug info | |
| // @match https://webappssecure.grad.uw.edu/* | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // Only run this script if we are on a "studentlist" page | |
| if (!window.location.href.toLowerCase().includes('studentlist')) { | |
| return; | |
| } | |
| console.log("[UW Exception Scanner] Script initialized on Student List page."); | |
| // ========== UI BUTTON ========== | |
| const btn = document.createElement('button'); | |
| btn.textContent = "🔍 Scan for PhD Time Extensions"; | |
| btn.style.cssText = "position:fixed; top:15px; left:50%; transform:translateX(-50%); z-index:10000; padding:10px 14px; background:#4b2e83; color:#fff; border:3px solid #b7a57a; border-radius:6px; cursor:pointer; font-weight:600; box-shadow:0 4px 8px rgba(0,0,0,0.35); font-size:13px; text-align:center; transition: all 0.2s ease;"; | |
| document.body.appendChild(btn); | |
| // Hover visual feedback | |
| btn.addEventListener('mouseenter', () => { if (!btn.disabled) btn.style.background = "#5b3b9c"; }); | |
| btn.addEventListener('mouseleave', () => { if (!btn.disabled) btn.style.background = "#4b2e83"; }); | |
| // ========== HELPERS ========== | |
| // Find all student links on the page and map them by their ID | |
| function getStudentsOnPage() { | |
| const studentMap = new Map(); | |
| document.querySelectorAll('a[href*="id="]').forEach(a => { | |
| const match = a.href.match(/id=(\d+)/i); | |
| if (match) { | |
| const id = match[1]; | |
| if (!studentMap.has(id)) { | |
| studentMap.set(id, a); | |
| } | |
| } | |
| }); | |
| return studentMap; | |
| } | |
| // Load page in hidden iframe, wait 10 seconds, and search #tblHistory | |
| const checkExceptionStatus = (url, studentId) => new Promise((resolve) => { | |
| console.log(`[UW Exception Scanner] [ID: ${studentId}] Creating hidden iframe to fetch: ${url}`); | |
| const iframe = document.createElement('iframe'); | |
| iframe.style.cssText = "display:none;position:fixed;left:-9999px;"; | |
| document.body.appendChild(iframe); | |
| const cleanup = () => { try { iframe.remove(); } catch(e) {} }; | |
| iframe.onload = () => { | |
| console.log(`[UW Exception Scanner] [ID: ${studentId}] Iframe loaded. Starting 10-second wait for Angular to render tables...`); | |
| // Wait exactly 10 seconds for UW's Angular scripts to render the table data | |
| setTimeout(() => { | |
| console.log(`[UW Exception Scanner] [ID: ${studentId}] 10 seconds passed. Inspecting DOM...`); | |
| try { | |
| const doc = iframe.contentDocument || iframe.contentWindow.document; | |
| let found = false; | |
| // Look specifically for the Exception History table | |
| const historyTable = doc.getElementById('tblHistory'); | |
| if (historyTable) { | |
| // Extract just the text from the history column | |
| const historyText = historyTable.innerText || historyTable.textContent || ""; | |
| console.log(`[UW Exception Scanner][ID: ${studentId}] Found #tblHistory. Extracted ${historyText.length} characters of text.`); | |
| // Check if our target phrase is in that specific table | |
| if (historyText.toLowerCase().includes("extend time limit for a phd")) { | |
| console.log(`[UW Exception Scanner] [ID: ${studentId}] MATCH! Target phrase found in history text.`); | |
| found = true; | |
| } else { | |
| console.log(`[UW Exception Scanner] [ID: ${studentId}] Target phrase NOT found in history text.`); | |
| } | |
| } else { | |
| console.warn(`[UW Exception Scanner] [ID: ${studentId}] WARNING: Could not find #tblHistory element on the page. The student might not have any exceptions, or the page layout changed.`); | |
| } | |
| resolve(found); | |
| } catch (e) { | |
| console.error(`[UW Exception Scanner] [ID: ${studentId}] Iframe extraction failed!`, e); | |
| resolve(false); | |
| } finally { | |
| cleanup(); | |
| } | |
| }, 10000); // 10,000 milliseconds = 10 seconds | |
| }; | |
| iframe.onerror = () => { | |
| console.error(`[UW Exception Scanner] [ID: ${studentId}] Iframe failed to load (network error).`); | |
| cleanup(); | |
| resolve(false); | |
| }; | |
| iframe.src = url; | |
| }); | |
| // ========== MAIN ACTION ========== | |
| btn.addEventListener('click', async () => { | |
| console.log("\n[UW Exception Scanner] --- NEW SCAN INITIATED ---"); | |
| const students = getStudentsOnPage(); | |
| const total = students.size; | |
| if (total === 0) { | |
| console.warn("[UW Exception Scanner] Scan aborted: No students found on this page."); | |
| alert("No students found on this page!"); | |
| return; | |
| } | |
| console.log(`[UW Exception Scanner] Found ${total} unique student IDs to process.`, Array.from(students.keys())); | |
| // Clean up any existing badges or row highlights from previous scans | |
| document.querySelectorAll('.uw-exception-badge').forEach(el => el.remove()); | |
| document.querySelectorAll('tr').forEach(tr => tr.style.backgroundColor = ''); | |
| btn.disabled = true; | |
| btn.style.background = "#333"; | |
| let checked = 0; | |
| let found = 0; | |
| const exceptionBaseUrl = "https://webappssecure.grad.uw.edu/mgp-dept.stu.detail/exception/exceptions?id="; | |
| // Loop through all students sequentially | |
| for (const [id, linkElement] of students.entries()) { | |
| checked++; | |
| console.log(`\n[UW Exception Scanner] --- Processing student ${checked} of ${total} (ID: ${id}) ---`); | |
| // Update UI so the user knows the script hasn't frozen during the 10s wait | |
| btn.textContent = `⏳ Scanning ${checked} / ${total}... (10s per student. Found: ${found})`; | |
| const exceptionUrl = exceptionBaseUrl + id; | |
| const hasExtension = await checkExceptionStatus(exceptionUrl, id); | |
| // Create the badge element | |
| const badge = document.createElement('span'); | |
| badge.className = 'uw-exception-badge'; // Tag it so we can easily remove it later if rescanned | |
| badge.style.marginLeft = "8px"; | |
| badge.style.fontSize = "12px"; | |
| if (hasExtension) { | |
| found++; | |
| console.log(`[UW Exception Scanner] [ID: ${id}] Result: EXCEPTION FOUND. Highlighting row red.`); | |
| // Red Cross for Exception Found | |
| badge.innerHTML = "❌ <b style='color:#cc0000;'>[exception found]</b>"; | |
| // Highlight the entire row in the table light red | |
| const row = linkElement.closest('tr'); | |
| if (row) { | |
| row.style.backgroundColor = "#ffe6e6"; | |
| } | |
| } else { | |
| console.log(`[UW Exception Scanner] [ID: ${id}] Result: CLEAR. Adding green checkmark.`); | |
| // Green Checkmark for No Exception | |
| badge.innerHTML = "✅ <b style='color:#2e8b57;'>[no exception]</b>"; | |
| } | |
| // Append the badge right next to the student's name | |
| linkElement.parentNode.appendChild(badge); | |
| } | |
| console.log(`\n[UW Exception Scanner] --- SCAN COMPLETE ---`); | |
| console.log(`[UW Exception Scanner] Total Scanned: ${total} | Total Extensions Found: ${found}`); | |
| // Finish state | |
| btn.textContent = `✅ Scan Complete! Found ${found} extensions.`; | |
| btn.style.background = found > 0 ? "#cc0000" : "#2e8b57"; // Red if found, Green if clear | |
| setTimeout(() => { | |
| btn.textContent = "🔍 Scan for PhD Time Extensions"; | |
| btn.style.background = "#4b2e83"; | |
| btn.disabled = false; | |
| }, 5000); | |
| }); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| javascript:(function(){ const currentUrl = window.location.href; const quarterMatch = currentUrl.match(/\/timeschd\/([A-Z]{3}\d{4})/i); if (!quarterMatch) { alert("Please click this bookmarklet while on a UW Time Schedule page (e.g., /timeschd/SPR2026/)."); return; } const quarter = quarterMatch[1].toUpperCase(); const qtrYrStr = quarter.substring(0, 3) + '+' + quarter.substring(3); const baseUrl = 'https://www.washington.edu/students/timeschd/' + quarter + '/'; const newWin = window.open('', '_blank'); if(!newWin) { alert("Popup blocked! Please allow popups for washington.edu to generate the dashboard."); return; } const htmlContent = `<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>${quarter} Anthro Dashboard</title> <link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css"> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script> <script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script> <style> body { margin: 0; padding: 0; background-color: #f4f6f8; font-family: 'Open Sans', Arial, sans-serif; } .dashboard-header { background: #4b2e83; color: white; padding: 25px 40px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); } .dashboard-header h1 { margin: 0; font-size: 28px; font-weight: 600; } .dashboard-header p { margin: 5px 0 0 0; font-size: 15px; color: #e8e3d3; } .dashboard-links { margin-top: 15px; font-size: 14px; } .dashboard-links a { color: #fff; text-decoration: none; border-bottom: 1px dotted rgba(255,255,255,0.6); margin-right: 20px; transition: color 0.2s; } .dashboard-links a:hover { color: #b7a57a; border-bottom-style: solid; } .filter-bar { background: white; margin: 20px 40px 0; padding: 15px 25px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border-left: 5px solid #85754d; display: flex; flex-wrap: wrap; gap: 40px; align-items: center; } .filter-group { display: flex; gap: 15px; align-items: center; } .filter-group strong { color: #4b2e83; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; } .filter-label { display: flex; align-items: center; gap: 5px; font-size: 14px; color: #333; cursor: pointer; } .filter-label input { cursor: pointer; } .stats-row { display: flex; gap: 20px; padding: 20px 40px 0; } .stat-card { background: white; padding: 20px; border-radius: 8px; flex: 1; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border-left: 5px solid #b7a57a; } .stat-card h3 { margin: 0 0 10px 0; color: #555; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; } .stat-card .value { font-size: 32px; font-weight: bold; color: #4b2e83; } .charts-column { display: flex; flex-direction: column; gap: 20px; padding: 20px 40px; } .chart-container { width: 100%; background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); min-height: 450px; } .table-container { background: white; margin: 0 40px 40px; padding: 25px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); } a.sln-link { color: #4b2e83; font-weight: bold; text-decoration: none; border-bottom: 1px dotted #4b2e83; } a.sln-link:hover { color: #b7a57a; border-bottom-style: solid; } </style> </head> <body> <div id="loading" style="display: flex; justify-content: center; align-items: center; height: 100vh; flex-direction: column;"> <h1 style="color: #4b2e83;">Loading ${quarter} Anthropology Dashboard...</h1> <p style="color: #666;">Fetching, parsing, and cleaning course data in real-time.</p> </div> <div id="dashboard" style="display:none;"> <div class="dashboard-header"> <h1>UW Anthropology Analytics</h1> <p>${quarter} | Excludes 500+ level courses, independent studies (99s), and Honors classes.</p> <div class="dashboard-links"> <a href="${baseUrl}anthro.html" target="_blank">ANTH Schedule ↗</a> <a href="${baseUrl}bioanth.html" target="_blank">BIO A Schedule ↗</a> <a href="${baseUrl}archeo.html" target="_blank">ARCHY Schedule ↗</a> </div> </div> <div class="filter-bar"> <div class="filter-group"> <strong>Prefixes:</strong> <label class="filter-label"><input type="checkbox" class="prefix-filter" value="ANTH" checked> ANTH</label> <label class="filter-label"><input type="checkbox" class="prefix-filter" value="ARCHY" checked> ARCHY</label> <label class="filter-label"><input type="checkbox" class="prefix-filter" value="BIO A" checked> BIO A</label> </div> <div class="filter-group"> <strong>Gen Ed:</strong> <label class="filter-label"><input type="checkbox" class="gened-filter" value="SSc" checked> SSc</label> <label class="filter-label"><input type="checkbox" class="gened-filter" value="NSc" checked> NSc</label> <label class="filter-label"><input type="checkbox" class="gened-filter" value="None" checked> Unspecified/Other</label> </div> </div> <div class="stats-row"> <div class="stat-card"><h3>Filtered Sections</h3><div class="value" id="stat-sec">0</div></div> <div class="stat-card"><h3>Enrolled</h3><div class="value" id="stat-enrl">0</div></div> <div class="stat-card"><h3>Capacity</h3><div class="value" id="stat-lim">0</div></div> <div class="stat-card"><h3>Fullness</h3><div class="value" id="stat-pct">0%</div></div> </div> <div class="charts-column"> <div class="chart-container" id="chart-pct"></div> <div class="chart-container" id="chart-scatter"></div> </div> <div class="table-container"> <table id="course-table" class="display" style="width:100%"> <thead> <tr> <th>Course</th> <th>Sec</th> <th>Name</th> <th>Gen Ed</th> <th>Instructor</th> <th>Enrl</th> <th>Lim</th> <th>% Full</th> </tr> </thead> <tbody></tbody> </table> </div> </div> <script> (async function(){ const baseUrl = "${baseUrl}"; const qtrYrStr = "${qtrYrStr}"; const urls =[ baseUrl + 'archeo.html', baseUrl + 'bioanth.html', baseUrl + 'anthro.html' ]; let rawData =[]; function parseUWTimeSchedule(html) { const results =[]; let text = html.replace(/<br\\s*\\/?>/gi, '\\n').replace(/<\\/tr>/gi, '\\n') .replace(/<\\/table>/gi, '\\n').replace(/<\\/div>/gi, '\\n') .replace(/<\\/p>/gi, '\\n').replace(/<[^>]*>/g, ''); text = text.replace(/ /g, ' ').replace(/&/g, '&'); const lines = text.split('\\n'); let currentCourse = null; for (let i = 0; i < lines.length; i++) { let line = lines[i].trim(); if (!line) continue; const headerMatch = line.match(/^([A-Z]{3,5}(?:\\s+[A-Z])?)\\s+(\\d{3}[A-Z]?)\\s+(.*)/); if (headerMatch && !line.includes("SLN")) { let rawName = headerMatch[3].split(',')[0].trim().replace(/\\s{2,}/g, ' '); let numStr = headerMatch[2]; if (/^[5678]/.test(numStr) || numStr.endsWith('99') || /HONORS/i.test(rawName)) { currentCourse = null; continue; } let genEd =[]; if (/\\bSSc\\b/i.test(rawName)) genEd.push('SSc'); if (/\\bNSc\\b/i.test(rawName)) genEd.push('NSc'); let cleanName = rawName.replace(/\\(.*?(SSc|NSc|A&H).*?\\)/ig, '').replace(/Prerequisites/ig, '').trim(); currentCourse = { prefix: headerMatch[1].trim(), number: numStr, name: cleanName, genEd: genEd, genEdStr: genEd.join(', ') }; continue; } if (!currentCourse) continue; const slnMatch = line.match(/^(?:[A-Za-z]+\\s*)*>?\\s*(\\d{4,5})\\s+([A-Z0-9]{1,3})\\b/); if (slnMatch) { if (line.includes('QZ')) continue; const sln = slnMatch[1]; const section = slnMatch[2]; const enrlLimMatch = line.match(/(\\d+)\\s*\\/\\s*(\\d+)/); if (!enrlLimMatch) continue; const enrl = parseInt(enrlLimMatch[1], 10); const lim = parseInt(enrlLimMatch[2], 10); let instructor = "TBA"; const preEnrl = line.substring(0, line.indexOf(enrlLimMatch[0])); const instrMatch = preEnrl.match(/([A-Za-z\\-\\']+(?:\\s+[A-Za-z\\-\\']+)*,\\s*[A-Za-z\\-\\'\\s]+)/); if (instrMatch) { instructor = instrMatch[1].trim(); } else if (/STAFF/i.test(preEnrl)) { instructor = "STAFF"; } else if (/to be arranged/i.test(preEnrl)) { instructor = "TBA"; } else { const tokens = preEnrl.trim().split(/\\s{2,}/); if (tokens.length > 1) instructor = tokens[tokens.length - 1]; } instructor = instructor.replace(/\\s+(Open|Closed|Restr|Full-term)$/ig, '').trim(); if (!instructor) instructor = "TBA"; results.push({ sln: sln, prefix: currentCourse.prefix, number: currentCourse.number, section: section, name: currentCourse.name, genEd: currentCourse.genEd, genEdStr: currentCourse.genEdStr, instructor: instructor, enrl: enrl, lim: lim, level: currentCourse.number.charAt(0) + '00', pct: lim > 0 ? (enrl / lim) * 100 : 0 }); } } return results; } for (const url of urls) { try { const response = await fetch(url); if (response.ok) { const html = await response.text(); rawData = rawData.concat(parseUWTimeSchedule(html)); } } catch (e) { console.error("Fetch error:", e); } } document.getElementById('loading').style.display = 'none'; if (rawData.length === 0) { document.body.innerHTML = '<h2 style="color:red; text-align:center; margin-top:50px;">Failed to load data or no matching courses found.</h2>'; return; } document.getElementById('dashboard').style.display = 'block'; let dataTable; const colorBrewerSet2 = { 'ANTH': '#8da0cb', 'ARCHY': '#fc8d62', 'BIO A': '#66c2a5' }; function renderDashboard() { const activePrefixes = $('.prefix-filter:checked').map(function() { return this.value; }).get(); const activeGenEds = $('.gened-filter:checked').map(function() { return this.value; }).get(); const filteredData = rawData.filter(d => { const matchPrefix = activePrefixes.includes(d.prefix); let matchGenEd = false; if (d.genEd.length === 0 && activeGenEds.includes("None")) matchGenEd = true; if (d.genEd.includes("SSc") && activeGenEds.includes("SSc")) matchGenEd = true; if (d.genEd.includes("NSc") && activeGenEds.includes("NSc")) matchGenEd = true; return matchPrefix && matchGenEd; }); const totalLim = filteredData.reduce((s, d) => s + d.lim, 0); const totalEnrl = filteredData.reduce((s, d) => s + d.enrl, 0); $('#stat-sec').text(filteredData.length); $('#stat-enrl').text(totalEnrl); $('#stat-lim').text(totalLim); $('#stat-pct').text(totalLim > 0 ? ((totalEnrl / totalLim) * 100).toFixed(1) + '%' : '0%'); const plotData =[]; if (filteredData.length > 0) { plotData.push({ y: filteredData.map(d => d.pct), x: filteredData.map(d => 'Level ' + d.level), type: 'box', name: 'All Combined', boxpoints: false, line: { color: '#777', width: 2 }, fillcolor: 'rgba(200, 200, 200, 0.3)', showlegend: false, hoverinfo: 'y' }); } activePrefixes.forEach(prefix => { const prefixData = filteredData.filter(d => d.prefix === prefix && d.lim > 0); if (prefixData.length === 0) return; plotData.push({ y: prefixData.map(d => d.pct), x: prefixData.map(d => 'Level ' + d.level), type: 'box', name: prefix, boxpoints: 'all', jitter: 0.5, pointpos: -1.8, fillcolor: 'rgba(0,0,0,0)', line: { color: 'rgba(0,0,0,0)' }, marker: { size: 14, color: colorBrewerSet2[prefix] || '#e78ac3', line: {color: '#ffffff', width: 1} }, text: prefixData.map(d => d.prefix + ' ' + d.number + ' ' + d.section + '<br>' + d.name + '<br>' + d.instructor + '<br>Enrl: ' + d.enrl + '/' + d.lim + ' (' + d.pct.toFixed(1) + '%)'), hoverinfo: 'text' }); }); Plotly.react('chart-pct', plotData, { title: 'Class Fullness by Course Level (Click Legend to toggle points)', yaxis: { title: 'Percentage Enrolled (%)', zeroline: false }, xaxis: { title: 'Course Level', categoryorder: 'category ascending' }, boxmode: 'overlay', showlegend: true, autosize: true, margin: { t: 40, l: 50, r: 20, b: 40 } }, { responsive: true }); const scatterData =[]; activePrefixes.forEach(prefix => { const prefixData = filteredData.filter(d => d.prefix === prefix && d.lim > 0); if (prefixData.length === 0) return; scatterData.push({ x: prefixData.map(d => d.lim), y: prefixData.map(d => d.enrl), mode: 'markers', type: 'scatter', name: prefix, marker: { size: 14, color: colorBrewerSet2[prefix] || '#e78ac3', opacity: 0.85, line: {color: '#ffffff', width: 1} }, text: prefixData.map(d => d.prefix + ' ' + d.number + ' ' + d.section + '<br>' + d.name + '<br>' + d.instructor + '<br>Enrl: ' + d.enrl + '/' + d.lim), hoverinfo: 'text' }); }); const maxLim = Math.max(...filteredData.map(d => d.lim), 10); scatterData.push({ x:[0, maxLim], y: [0, maxLim], mode: 'lines', type: 'scatter', name: '100% Full Reference', line: { dash: 'dash', color: '#ff0000', width: 1.5 }, hoverinfo: 'none' }); Plotly.react('chart-scatter', scatterData, { title: 'Enrolled Students vs. Capacity Limits (Click Legend to toggle points)', xaxis: { title: 'Capacity Limit' }, yaxis: { title: 'Students Enrolled' }, showlegend: true, autosize: true, margin: { t: 40, l: 50, r: 20, b: 40 } }, { responsive: true }); if (!dataTable) { dataTable = $('#course-table').DataTable({ data: filteredData, columns:[ { data: null, render: (data, type, row) => '<a class="sln-link" href="https://sdb.admin.uw.edu/timeschd/uwnetid/sln.asp?QTRYR=' + qtrYrStr + '&SLN=' + row.sln + '" target="_blank" title="View SLN Registration Page">' + row.prefix + ' ' + row.number + '</a>' }, { data: 'section' }, { data: 'name' }, { data: 'genEdStr', render: (data) => data || '<span style="color:#aaa;">-</span>' }, { data: 'instructor' }, { data: 'enrl' }, { data: 'lim' }, { data: 'pct', render: function(data, type) { if (type === 'display' || type === 'filter') { let emoji = (data >= 75 && data <= 100) ? '🟢' : (data > 100 || (data >= 50 && data < 75)) ? '🟠' : '🔴'; return emoji + ' ' + data.toFixed(1) + '%'; } return data; } } ], pageLength: 25, order: [[7, 'asc']], language: { search: "Text Search:" } }); } else { dataTable.clear(); dataTable.rows.add(filteredData); dataTable.draw(); } } renderDashboard(); $('.prefix-filter, .gened-filter').on('change', renderDashboard); window.addEventListener('resize', function() { Plotly.Plots.resize('chart-pct'); Plotly.Plots.resize('chart-scatter'); }); })(); </script> </body> </html>%60; newWin.document.write(htmlContent); newWin.document.close();})(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name UW Time Schedule Anthropology Undergrad Dashboard | |
| // @namespace http://tampermonkey.net/ | |
| // @version 5.0 | |
| // @description Adds a button to open an interactive Anthropology dashboard. Filters out >=500s, 99s, Honors. Adds health emojis. | |
| // @author You | |
| // @match https://www.washington.edu/students/timeschd/* | |
| // @require https://code.jquery.com/jquery-3.6.0.min.js | |
| // @require https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js | |
| // @require https://cdn.plot.ly/plotly-2.27.0.min.js | |
| // @grant none | |
| // ==/UserScript== | |
| (async function() { | |
| 'use strict'; | |
| // 1. EXTRACT QUARTER & CONTEXT | |
| const currentUrl = window.location.href; | |
| const quarterMatch = currentUrl.match(/\/timeschd\/([A-Z]{3}\d{4})/i); | |
| if (!quarterMatch) return; | |
| const quarter = quarterMatch[1].toUpperCase(); // e.g., SPR2026 | |
| const qtrYrStr = quarter.substring(0, 3) + '+' + quarter.substring(3); // e.g., SPR+2026 (for SLN link) | |
| const baseUrl = `https://www.washington.edu/students/timeschd/${quarter}/`; | |
| const isDashboard = new URL(currentUrl).searchParams.get('showAnthDashboard') === 'true'; | |
| // 2. INJECT BUTTON ON NORMAL PAGES | |
| if (!isDashboard) { | |
| if (!document.getElementById('uw-anth-dash-btn')) { | |
| const btn = document.createElement('button'); | |
| btn.id = 'uw-anth-dash-btn'; | |
| btn.innerText = '📊 Open Anthropology Dashboard'; | |
| btn.style.cssText = ` | |
| position: fixed; bottom: 30px; right: 30px; z-index: 999999; | |
| background: #4b2e83; color: white; padding: 15px 25px; | |
| border: 2px solid white; border-radius: 8px; cursor: pointer; | |
| font-size: 16px; font-weight: bold; box-shadow: 0 4px 10px rgba(0,0,0,0.4); | |
| font-family: 'Open Sans', Arial, sans-serif; transition: background 0.2s; | |
| `; | |
| btn.onmouseover = () => btn.style.background = '#3a2365'; | |
| btn.onmouseout = () => btn.style.background = '#4b2e83'; | |
| btn.onclick = (e) => { | |
| e.preventDefault(); | |
| window.open(baseUrl + '?showAnthDashboard=true', '_blank'); | |
| }; | |
| document.body.appendChild(btn); | |
| } | |
| return; // Stop execution on the native page | |
| } | |
| // ========================================================================= | |
| // 3. IF WE ARE IN THE NEW DASHBOARD TAB, TAKE OVER THE SCREEN & RUN LOGIC | |
| // ========================================================================= | |
| document.body.innerHTML = ` | |
| <div style="display: flex; justify-content: center; align-items: center; height: 100vh; font-family: 'Open Sans', Arial, sans-serif; background: #f4f6f8; flex-direction: column;"> | |
| <h1 style="color: #4b2e83;">Loading ${quarter} Anthropology Dashboard...</h1> | |
| <p style="color: #666;">Fetching, parsing, and cleaning course data.</p> | |
| </div> | |
| `; | |
| const urls =[ baseUrl + 'archeo.html', baseUrl + 'bioanth.html', baseUrl + 'anthro.html' ]; | |
| let rawData =[]; | |
| // Parsing Logic | |
| function parseUWTimeSchedule(html) { | |
| const results =[]; | |
| let text = html.replace(/<br\s*\/?>/gi, '\n').replace(/<\/tr>/gi, '\n') | |
| .replace(/<\/table>/gi, '\n').replace(/<\/div>/gi, '\n') | |
| .replace(/<\/p>/gi, '\n').replace(/<[^>]*>/g, ''); | |
| text = text.replace(/ /g, ' ').replace(/&/g, '&'); | |
| const lines = text.split('\n'); | |
| let currentCourse = null; | |
| for (let i = 0; i < lines.length; i++) { | |
| let line = lines[i].trim(); | |
| if (!line) continue; | |
| // Allow for optional W suffix on course numbers just in case | |
| const headerMatch = line.match(/^([A-Z]{3,5}(?:\s+[A-Z])?)\s+(\d{3}[A-Z]?)\s+(.*)/); | |
| if (headerMatch && !line.includes("SLN")) { | |
| let rawName = headerMatch[3].split(',')[0].trim().replace(/\s{2,}/g, ' '); | |
| let numStr = headerMatch[2]; | |
| // Exclude 500, 600, 700, 800+ and anything ending in 99 | |
| if (/^[5678]/.test(numStr) || numStr.endsWith('99')) { | |
| currentCourse = null; | |
| continue; | |
| } | |
| // Exclude HONORS courses | |
| if (/HONORS/i.test(rawName)) { | |
| currentCourse = null; | |
| continue; | |
| } | |
| let genEd =[]; | |
| if (/\bSSc\b/i.test(rawName)) genEd.push('SSc'); | |
| if (/\bNSc\b/i.test(rawName)) genEd.push('NSc'); | |
| let cleanName = rawName.replace(/\(.*?(SSc|NSc|A&H).*?\)/ig, '').replace(/Prerequisites/ig, '').trim(); | |
| currentCourse = { prefix: headerMatch[1].trim(), number: numStr, name: cleanName, genEd: genEd, genEdStr: genEd.join(', ') }; | |
| continue; | |
| } | |
| if (!currentCourse) continue; | |
| // PATCH: Bypass "Restr", "IS", or "H" tags pushed to the start of the row | |
| const slnMatch = line.match(/^(?:[A-Za-z]+\s*)*>?\s*(\d{4,5})\s+([A-Z0-9]{1,3})\b/); | |
| if (slnMatch) { | |
| if (line.includes('QZ')) continue; | |
| const sln = slnMatch[1]; | |
| const section = slnMatch[2]; | |
| const enrlLimMatch = line.match(/(\d+)\s*\/\s*(\d+)/); | |
| if (!enrlLimMatch) continue; | |
| const enrl = parseInt(enrlLimMatch[1], 10); | |
| const lim = parseInt(enrlLimMatch[2], 10); | |
| let instructor = "TBA"; | |
| const preEnrl = line.substring(0, line.indexOf(enrlLimMatch[0])); | |
| const instrMatch = preEnrl.match(/([A-Za-z\-\']+(?:\s+[A-Za-z\-\']+)*,\s*[A-Za-z\-\'\s]+)/); | |
| if (instrMatch) { | |
| instructor = instrMatch[1].trim(); | |
| } else if (/STAFF/i.test(preEnrl)) { | |
| instructor = "STAFF"; | |
| } else if (/to be arranged/i.test(preEnrl)) { | |
| instructor = "TBA"; | |
| } else { | |
| const tokens = preEnrl.trim().split(/\s{2,}/); | |
| if (tokens.length > 1) instructor = tokens[tokens.length - 1]; | |
| } | |
| instructor = instructor.replace(/\s+(Open|Closed|Restr|Full-term)$/ig, '').trim(); | |
| if (!instructor) instructor = "TBA"; | |
| results.push({ | |
| sln: sln, | |
| prefix: currentCourse.prefix, | |
| number: currentCourse.number, | |
| section: section, | |
| name: currentCourse.name, | |
| genEd: currentCourse.genEd, | |
| genEdStr: currentCourse.genEdStr, | |
| instructor: instructor, | |
| enrl: enrl, | |
| lim: lim, | |
| level: currentCourse.number.charAt(0) + '00', | |
| pct: lim > 0 ? (enrl / lim) * 100 : 0 | |
| }); | |
| } | |
| } | |
| return results; | |
| } | |
| // Fetch pages | |
| for (const url of urls) { | |
| try { | |
| const response = await fetch(url); | |
| if (response.ok) { | |
| const html = await response.text(); | |
| rawData = rawData.concat(parseUWTimeSchedule(html)); | |
| } | |
| } catch (e) { console.error("Fetch error:", e); } | |
| } | |
| if (rawData.length === 0) { | |
| document.body.innerHTML = `<h2 style="color:red; text-align:center; margin-top:50px;">Failed to load data or no matching courses found.</h2>`; | |
| return; | |
| } | |
| // 4. INJECT DASHBOARD UI | |
| const cssLink = document.createElement('link'); | |
| cssLink.rel = 'stylesheet'; | |
| cssLink.href = 'https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css'; | |
| document.head.appendChild(cssLink); | |
| document.title = `${quarter} Anthro Dashboard`; | |
| document.body.innerHTML = ` | |
| <style> | |
| body { margin: 0; padding: 0; background-color: #f4f6f8; font-family: 'Open Sans', Arial, sans-serif; } | |
| .dashboard-header { background: #4b2e83; color: white; padding: 25px 40px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); } | |
| .dashboard-header h1 { margin: 0; font-size: 28px; font-weight: 600; } | |
| .dashboard-header p { margin: 5px 0 0 0; font-size: 15px; color: #e8e3d3; } | |
| .dashboard-links { margin-top: 15px; font-size: 14px; } | |
| .dashboard-links a { color: #fff; text-decoration: none; border-bottom: 1px dotted rgba(255,255,255,0.6); margin-right: 20px; transition: color 0.2s; } | |
| .dashboard-links a:hover { color: #b7a57a; border-bottom-style: solid; } | |
| .filter-bar { background: white; margin: 20px 40px 0; padding: 15px 25px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border-left: 5px solid #85754d; display: flex; flex-wrap: wrap; gap: 40px; align-items: center; } | |
| .filter-group { display: flex; gap: 15px; align-items: center; } | |
| .filter-group strong { color: #4b2e83; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; } | |
| .filter-label { display: flex; align-items: center; gap: 5px; font-size: 14px; color: #333; cursor: pointer; } | |
| .filter-label input { cursor: pointer; } | |
| .stats-row { display: flex; gap: 20px; padding: 20px 40px 0; } | |
| .stat-card { background: white; padding: 20px; border-radius: 8px; flex: 1; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border-left: 5px solid #b7a57a; } | |
| .stat-card h3 { margin: 0 0 10px 0; color: #555; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; } | |
| .stat-card .value { font-size: 32px; font-weight: bold; color: #4b2e83; } | |
| .charts-column { display: flex; flex-direction: column; gap: 20px; padding: 20px 40px; } | |
| .chart-container { width: 100%; background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); min-height: 450px; } | |
| .table-container { background: white; margin: 0 40px 40px; padding: 25px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); } | |
| a.sln-link { color: #4b2e83; font-weight: bold; text-decoration: none; border-bottom: 1px dotted #4b2e83; } | |
| a.sln-link:hover { color: #b7a57a; border-bottom-style: solid; } | |
| </style> | |
| <div class="dashboard-header"> | |
| <h1>UW Anthropology Analytics</h1> | |
| <p>${quarter} | Excludes 500+ level courses, independent studies (99s), and Honors classes.</p> | |
| <div class="dashboard-links"> | |
| <a href="${baseUrl}anthro.html" target="_blank">ANTH Schedule ↗</a> | |
| <a href="${baseUrl}bioanth.html" target="_blank">BIO A Schedule ↗</a> | |
| <a href="${baseUrl}archeo.html" target="_blank">ARCHY Schedule ↗</a> | |
| </div> | |
| </div> | |
| <div class="filter-bar"> | |
| <div class="filter-group"> | |
| <strong>Prefixes:</strong> | |
| <label class="filter-label"><input type="checkbox" class="prefix-filter" value="ANTH" checked> ANTH</label> | |
| <label class="filter-label"><input type="checkbox" class="prefix-filter" value="ARCHY" checked> ARCHY</label> | |
| <label class="filter-label"><input type="checkbox" class="prefix-filter" value="BIO A" checked> BIO A</label> | |
| </div> | |
| <div class="filter-group"> | |
| <strong>Gen Ed:</strong> | |
| <label class="filter-label"><input type="checkbox" class="gened-filter" value="SSc" checked> SSc</label> | |
| <label class="filter-label"><input type="checkbox" class="gened-filter" value="NSc" checked> NSc</label> | |
| <label class="filter-label"><input type="checkbox" class="gened-filter" value="None" checked> Unspecified/Other</label> | |
| </div> | |
| </div> | |
| <div class="stats-row"> | |
| <div class="stat-card"><h3>Filtered Sections</h3><div class="value" id="stat-sec">0</div></div> | |
| <div class="stat-card"><h3>Enrolled</h3><div class="value" id="stat-enrl">0</div></div> | |
| <div class="stat-card"><h3>Capacity</h3><div class="value" id="stat-lim">0</div></div> | |
| <div class="stat-card"><h3>Fullness</h3><div class="value" id="stat-pct">0%</div></div> | |
| </div> | |
| <div class="charts-column"> | |
| <div class="chart-container" id="chart-pct"></div> | |
| <div class="chart-container" id="chart-scatter"></div> | |
| </div> | |
| <div class="table-container"> | |
| <table id="course-table" class="display" style="width:100%"> | |
| <thead> | |
| <tr> | |
| <th>Course</th> | |
| <th>Sec</th> | |
| <th>Name</th> | |
| <th>Gen Ed</th> | |
| <th>Instructor</th> | |
| <th>Enrl</th> | |
| <th>Lim</th> | |
| <th>% Full</th> | |
| </tr> | |
| </thead> | |
| <tbody></tbody> | |
| </table> | |
| </div> | |
| `; | |
| // 5. CHART & TABLE RENDERING | |
| let dataTable; | |
| const colorBrewerSet2 = { | |
| 'ANTH': '#8da0cb', // Light Blue/Purple | |
| 'ARCHY': '#fc8d62', // Orange | |
| 'BIO A': '#66c2a5' // Teal/Green | |
| }; | |
| function renderDashboard() { | |
| const activePrefixes = $('.prefix-filter:checked').map(function() { return this.value; }).get(); | |
| const activeGenEds = $('.gened-filter:checked').map(function() { return this.value; }).get(); | |
| const filteredData = rawData.filter(d => { | |
| const matchPrefix = activePrefixes.includes(d.prefix); | |
| let matchGenEd = false; | |
| if (d.genEd.length === 0 && activeGenEds.includes("None")) matchGenEd = true; | |
| if (d.genEd.includes("SSc") && activeGenEds.includes("SSc")) matchGenEd = true; | |
| if (d.genEd.includes("NSc") && activeGenEds.includes("NSc")) matchGenEd = true; | |
| return matchPrefix && matchGenEd; | |
| }); | |
| const totalLim = filteredData.reduce((s, d) => s + d.lim, 0); | |
| const totalEnrl = filteredData.reduce((s, d) => s + d.enrl, 0); | |
| $('#stat-sec').text(filteredData.length); | |
| $('#stat-enrl').text(totalEnrl); | |
| $('#stat-lim').text(totalLim); | |
| $('#stat-pct').text(totalLim > 0 ? ((totalEnrl / totalLim) * 100).toFixed(1) + '%' : '0%'); | |
| const plotData =[]; | |
| activePrefixes.forEach(prefix => { | |
| const prefixData = filteredData.filter(d => d.prefix === prefix && d.lim > 0); | |
| if (prefixData.length === 0) return; | |
| plotData.push({ | |
| y: prefixData.map(d => d.pct), | |
| x: prefixData.map(d => `Level ${d.level}`), | |
| type: 'box', | |
| name: prefix, | |
| boxpoints: 'all', | |
| jitter: 0.4, | |
| pointpos: -1.8, | |
| marker: { size: 14, color: colorBrewerSet2[prefix] || '#e78ac3' }, | |
| text: prefixData.map(d => `${d.prefix} ${d.number} ${d.section}<br>${d.name}<br>${d.instructor}<br>Enrl: ${d.enrl}/${d.lim} (${d.pct.toFixed(1)}%)`), | |
| hoverinfo: 'text' | |
| }); | |
| }); | |
| Plotly.react('chart-pct', plotData, { | |
| title: 'Class Fullness by Course Level & Prefix (Click Legend to Filter)', | |
| yaxis: { title: 'Percentage Enrolled (%)', zeroline: false }, | |
| xaxis: { title: 'Course Level', categoryorder: 'category ascending' }, | |
| boxmode: 'group', | |
| showlegend: true, | |
| autosize: true, | |
| margin: { t: 40, l: 50, r: 20, b: 40 } | |
| }, { responsive: true }); | |
| const scatterData =[]; | |
| activePrefixes.forEach(prefix => { | |
| const prefixData = filteredData.filter(d => d.prefix === prefix && d.lim > 0); | |
| if (prefixData.length === 0) return; | |
| scatterData.push({ | |
| x: prefixData.map(d => d.lim), | |
| y: prefixData.map(d => d.enrl), | |
| mode: 'markers', | |
| type: 'scatter', | |
| name: prefix, | |
| marker: { size: 14, color: colorBrewerSet2[prefix] || '#e78ac3', opacity: 0.85, line: {color: '#ffffff', width: 1} }, | |
| text: prefixData.map(d => `${d.prefix} ${d.number} ${d.section}<br>${d.name}<br>${d.instructor}<br>Enrl: ${d.enrl}/${d.lim}`), | |
| hoverinfo: 'text' | |
| }); | |
| }); | |
| const maxLim = Math.max(...filteredData.map(d => d.lim), 10); | |
| scatterData.push({ | |
| x:[0, maxLim], | |
| y: [0, maxLim], | |
| mode: 'lines', | |
| type: 'scatter', | |
| name: '100% Full Reference', | |
| line: { dash: 'dash', color: '#ff0000', width: 1.5 }, | |
| hoverinfo: 'none' | |
| }); | |
| Plotly.react('chart-scatter', scatterData, { | |
| title: 'Enrolled Students vs. Capacity Limits (Click Legend to Filter)', | |
| xaxis: { title: 'Capacity Limit' }, | |
| yaxis: { title: 'Students Enrolled' }, | |
| showlegend: true, | |
| autosize: true, | |
| margin: { t: 40, l: 50, r: 20, b: 40 } | |
| }, { responsive: true }); | |
| if (!dataTable) { | |
| dataTable = $('#course-table').DataTable({ | |
| data: filteredData, | |
| columns:[ | |
| { | |
| data: null, | |
| render: (data, type, row) => `<a class="sln-link" href="https://sdb.admin.uw.edu/timeschd/uwnetid/sln.asp?QTRYR=${qtrYrStr}&SLN=${row.sln}" target="_blank" title="View SLN Registration Page">${row.prefix} ${row.number}</a>` | |
| }, | |
| { data: 'section' }, | |
| { data: 'name' }, | |
| { data: 'genEdStr', render: (data) => data || `<span style="color:#aaa;">-</span>` }, | |
| { data: 'instructor' }, | |
| { data: 'enrl' }, | |
| { data: 'lim' }, | |
| { | |
| data: 'pct', | |
| render: function(data, type, row) { | |
| if (type === 'display' || type === 'filter') { | |
| let emoji = ''; | |
| if (data >= 75 && data <= 100) emoji = '🟢'; | |
| else if (data > 100 || (data >= 50 && data < 75)) emoji = '🟠'; | |
| else emoji = '🔴'; | |
| return `${emoji} ${data.toFixed(1)}%`; | |
| } | |
| // Returns raw numeric value for sorting purposes | |
| return data; | |
| } | |
| } | |
| ], | |
| pageLength: 25, | |
| order: [[7, 'asc']], | |
| language: { search: "Text Search:" } | |
| }); | |
| } else { | |
| dataTable.clear(); | |
| dataTable.rows.add(filteredData); | |
| dataTable.draw(); | |
| } | |
| } | |
| $(document).ready(function() { | |
| renderDashboard(); | |
| $('.prefix-filter, .gened-filter').on('change', renderDashboard); | |
| window.addEventListener('resize', function() { | |
| Plotly.Plots.resize('chart-pct'); | |
| Plotly.Plots.resize('chart-scatter'); | |
| }); | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment