RDS3 Assistant - Faculty Initial + Time

Show time/faculty, enhance search, remove courses by prefix, Update data to rds2.northsouth.app

当前为 2025-09-14 提交的版本,查看 最新版本

// ==UserScript==
// @name         RDS3 Assistant - Faculty Initial + Time
// @namespace    https://northsouth.app/
// @version      2.1.1
// @description  Show time/faculty, enhance search, remove courses by prefix, Update data to rds2.northsouth.app
// @author       Nihal, Rayed & Walid - NSU CTRL ALT DELETE (NSU - CSE231)
// @match        https://rds3.northsouth.edu/*
// @match        https://rds3.northsouth.edu/students/*
// @match        https://rds3.northsouth.edu/students/advising*
// @match        https://rds3.northsouth.edu/index.php/students/advising
// @icon         https://rds3.northsouth.edu/assets/img/favicon.png
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        unsafeWindow
// @license      MIT
// @connect      rds2csv.northsouth.app
// ==/UserScript==

(function () {
  'use strict';

  /******** PART 1: AUTO PUSH CSV FUNCTIONALITY ********/
  /******** CONFIG ********/
  const API_BASE = GM_getValue('API_BASE', 'https://rds2csv.northsouth.app/data'); // -> POST {API_BASE}/csv
  const RUN_KEY  = GM_getValue('RUN_KEY',  ''); // set only if your server checks X-Run-Key
  const FILENAME = 'course_data.csv';
  const COURSE_CELL_SELECTOR = 'td[onclick*="addNewCourses"]';

  // Observer & fallback timing
  const waitTimeoutMs  = 5000; // wait up to 5s for cells to appear
  const pollFallbackMs = 800;  // also try once after 800ms even if no cells (site sometimes builds time map first)
  const alreadyRanKey  = `rds3_auto_push_once_${Date.now()}`;

  // (No per-URL run-lock anymore; we want this to try each load)
  console.log('[RDS3 AutoPush] Script loaded');

  /******** ROOM MAP (as provided) ********/
  const roomArr = [];
  roomArr[0]="TBA";roomArr[1]="NAC601";roomArr[2]="NAC602";roomArr[3]="NAC603";roomArr[4]="NAC604";roomArr[5]="NAC605";
  roomArr[6]="NAC501";roomArr[7]="NAC502";roomArr[8]="NAC503";roomArr[9]="NAC504";roomArr[10]="NAC505";roomArr[11]="NAC506";
  roomArr[12]="NAC507";roomArr[13]="NAC508";roomArr[14]="NAC509";roomArr[15]="NAC510";roomArr[16]="NAC409";roomArr[17]="NAC410";
  roomArr[18]="NAC411";roomArr[19]="NAC414";roomArr[20]="NAC309";roomArr[21]="NAC310";roomArr[22]="NAC206";roomArr[23]="NAC207";
  roomArr[24]="NAC208";roomArr[25]="NAC209";roomArr[26]="NAC210";roomArr[27]="SAC401";roomArr[28]="SAC402";roomArr[29]="SAC403";
  roomArr[30]="SAC404";roomArr[31]="SAC405";roomArr[32]="SAC406";roomArr[33]="SAC407";roomArr[34]="NAC401";roomArr[35]="NAC402";
  roomArr[36]="NAC403";roomArr[37]="NAC404";roomArr[38]="NAC405";roomArr[39]="NAC406";roomArr[40]="NAC407";roomArr[41]="NAC408";
  roomArr[42]="NAC412";roomArr[43]="NAC413";roomArr[44]="SAC304";roomArr[45]="SAC305";roomArr[46]="SAC306";roomArr[47]="SAC307";
  roomArr[48]="SAC308";roomArr[49]="SAC309";roomArr[50]="SAC310";roomArr[51]="SAC311";roomArr[52]="SAC312";roomArr[53]="SAC313";
  roomArr[54]="SAC201";roomArr[55]="SAC207";roomArr[56]="SAC208";roomArr[57]="NAC301";roomArr[58]="NAC302";roomArr[59]="NAC303";
  roomArr[60]="NAC304";roomArr[61]="NAC305";roomArr[62]="NAC306";roomArr[63]="NAC307";roomArr[64]="NAC308";roomArr[65]="SAC203";
  roomArr[66]="SAC209";roomArr[67]="SAC210";roomArr[68]="SAC211";roomArr[69]="NAC201";roomArr[70]="NAC202";roomArr[71]="NAC203";
  roomArr[72]="NAC204";roomArr[73]="NAC205";roomArr[81]="LAB1";roomArr[82]="LAB2";roomArr[84]="LAB4";roomArr[85]="OAT1001";
  roomArr[86]="OAT1002";roomArr[87]="OAT1003";roomArr[89]="OAT901";roomArr[90]="OAT902";roomArr[91]="OAT903";roomArr[93]="OAT803";
  roomArr[94]="LIB901";roomArr[95]="LIB902";roomArr[96]="LIB903";roomArr[97]="LIB904";roomArr[98]="LIB905";roomArr[100]="LIB907";
  roomArr[101]="LIB908";roomArr[103]="SAC802";roomArr[121]="NAC514";roomArr[122]="NAC415";roomArr[123]="NAC311";roomArr[124]="SAC202";
  roomArr[128]="NAC213";roomArr[129]="NAC214";roomArr[130]="NAC215";roomArr[131]="NAC216";roomArr[132]="SAC204";roomArr[133]="NAC517";
  roomArr[135]="OAT601";roomArr[136]="OAT602";roomArr[144]="NAC990";roomArr[145]="NAC991";roomArr[146]="NAC992";roomArr[147]="NAC993";
  roomArr[148]="LIB906";roomArr[150]="SAC501";roomArr[151]="SAC502";roomArr[152]="SAC504";roomArr[153]="SAC508";roomArr[154]="SAC206";
  roomArr[155]="SAC205";roomArr[156]="NAC620";roomArr[158]="SAC510";roomArr[159]="SAC512";roomArr[160]="SAC514";roomArr[162]="SAC511";
  roomArr[163]="SAC513";roomArr[164]="LIB602";roomArr[165]="LIB603";roomArr[166]="LIB601";roomArr[174]="NAC621";roomArr[175]="NAC619";
  roomArr[176]="NAC619A";roomArr[177]="NAC211";roomArr[178]="LIB604";roomArr[179]="LIB605";roomArr[180]="LIB606";roomArr[181]="LIB607";
  roomArr[182]="LIB608";roomArr[183]="SAC506";roomArr[184]="SAC507";roomArr[185]="SAC1018";roomArr[186]="NAC511";roomArr[187]="LIB609";
  roomArr[188]="LIB610";roomArr[189]="LIB611";roomArr[190]="SAC509";roomArr[192]="SAC503";roomArr[193]="NAC512";roomArr[194]="NAC513";
  roomArr[195]="NAC313";roomArr[196]="NAC314";roomArr[197]="NAC315";roomArr[198]="SAC314";roomArr[199]="SAC315";roomArr[200]="SAC316";
  roomArr[203]="B113";roomArr[204]="B115";roomArr[205]="B117";roomArr[206]="B118";roomArr[207]="B310A";roomArr[209]="SAC724";
  roomArr[210]="SAC726";roomArr[211]="SAC409";roomArr[212]="SAC412";roomArr[213]="SAC413";roomArr[214]="SAC414";roomArr[215]="SAC415";
  roomArr[221]="OAT803_V";roomArr[224]="LIB903_V";roomArr[225]="LIB906_V";roomArr[230]="LIB901_V";roomArr[232]="LIB905_V";roomArr[234]="OAT902_V";
  roomArr[235]="SAC411";roomArr[236]="SAC411_V";roomArr[244]="NAC617";roomArr[245]="NAC618";roomArr[246]="NAC1077";roomArr[247]="NAC1078";
  roomArr[248]="NAC1079";roomArr[249]="NAC1080";roomArr[252]="SAC726_V";roomArr[254]="SAC412_V";roomArr[255]="SAC414_V";roomArr[256]="SAC413_V";
  roomArr[260]="SAC409_V";roomArr[266]="NAC505";roomArr[275]="SAC726_V1";roomArr[280]="LIB910";roomArr[281]="LIB913";roomArr[284]="LIB913_V";
  roomArr[286]="LIB1002";roomArr[287]="LIB1002_V";roomArr[288]="SAC801A";roomArr[290]="OAT1002_V";roomArr[292]="LIB902_V";roomArr[293]="LIB904_V";
  roomArr[294]="";roomArr[295]="SAC415_v1";roomArr[305]="OAT803_V1";roomArr[306]="TBA_v2";roomArr[307]="SAC801";roomArr[308]="OAT903_V1";
  roomArr[309]="LIB904_V1";roomArr[320]="LIB901_v1";roomArr[321]="SAC415B";roomArr[326]="SAC415B_V";roomArr[332]="Upper Plaza";roomArr[341]="NAC515";
  roomArr[343]="OAT803_V2";roomArr[345]="TV LAB";roomArr[351]="NAC201-v1";roomArr[352]="LIB902_V1";roomArr[358]="TV STUDIO";roomArr[360]="NAC512A";
  roomArr[361]="NTR304";roomArr[362]="NAC514";roomArr[363]="NTR301";

  /******** TIME RESOLUTION HELPERS ********/
  function getGlobalTimeMap() {
    const names = ['timeArray','TimeArray','TIME_ARRAY','timeArr','times','courseTimeArray'];
    for (const n of names) {
      try { if (n in unsafeWindow) { const v = unsafeWindow[n]; if (v && (Array.isArray(v)||typeof v==='object')) return v; } } catch {}
      try { if (n in window)      { const v = window[n];      if (v && (Array.isArray(v)||typeof v==='object')) return v; } } catch {}
    }
    return null;
  }

  function buildTimeMapFromScripts() {
    const map = {};
    const scripts = Array.from(document.scripts);
    const assignRe = /(?:^|[\s;])([A-Za-z_$][\w$]*)\s*\[\s*(\d+)\s*\]\s*=\s*["']([^"']+)["']/g;

    for (const s of scripts) {
      const txt = s.textContent || '';
      let m;
      while ((m = assignRe.exec(txt)) !== null) {
        const idx = parseInt(m[2], 10);
        const val = m[3].trim();
        if (!Number.isNaN(idx) && val) map[idx] = val;
      }
    }

    // Also try literal array/object assignments
    if (Object.keys(map).length === 0) {
      const all = scripts.map(s => s.textContent || '').join('\n');
      const literalRe = /([A-Za-z_$][\w$]*)\s*=\s*(\{[\s\S]*?\}|\[[\s\S]*?\]);/g;
      let m;
      while ((m = literalRe.exec(all)) !== null) {
        try {
          const obj = Function(`"use strict";return (${m[2]});`)();
          if (obj && typeof obj === 'object') {
            if (Array.isArray(obj)) obj.forEach((v,i)=>{ if (typeof v==='string') map[i]=v; });
            else for (const [k,v] of Object.entries(obj)) {
              const idx = parseInt(k,10);
              if (!Number.isNaN(idx) && typeof v==='string') map[idx]=v;
            }
          }
        } catch {}
      }
    }
    return map;
  }

  function resolveCourseTime(timeId) {
    const id = Number.parseInt(timeId, 10);
    if (Number.isNaN(id)) return 'Unknown';

    const g = getGlobalTimeMap();
    if (g) {
      const v = Array.isArray(g) ? g[id] : g[id] || g[String(id)];
      if (typeof v === 'string' && v.trim()) return v.trim();
    }
    if (!window.__rds_time_map__) window.__rds_time_map__ = buildTimeMapFromScripts();
    const m = window.__rds_time_map__;
    if (m && typeof m[id] === 'string' && m[id].trim()) return m[id].trim();
    return 'Unknown';
  }

  /******** CSV + UPLOAD ********/
  function csvEscape(val){ if(val==null) return ''; const s=String(val); return /[",\n]/.test(s) ? `"${s.replace(/"/g,'""')}"` : s; }
  function convertToCSV(courses){
    const ts = new Date().toISOString();
    const headers = ['CourseCode','Section','Faculty','CourseTime','TotalSeat','TakenSeat','RoomId','Room'];
    const lines = [`# lastUpdated: ${ts}`, headers.join(',')];
    for(const c of courses){
      lines.push([csvEscape(c.CourseCode),csvEscape(c.Section),csvEscape(c.Faculty),
                  csvEscape(c.CourseTime),csvEscape(c.TotalSeat),csvEscape(c.TakenSeat),
                  csvEscape(c.RoomId),csvEscape(c.Room)].join(','));
    }
    return lines.join('\n')+'\n';
  }
  function makeMultipartBody(fieldName, filename, content){
    const boundary = '----TMFormBoundary' + Math.random().toString(36).slice(2);
    const CRLF = '\r\n';
    const parts = [];
    parts.push(`--${boundary}${CRLF}`);
    parts.push(`Content-Disposition: form-data; name="${fieldName}"; filename="${filename}"${CRLF}`);
    parts.push(`Content-Type: text/csv${CRLF}${CRLF}`);
    parts.push(content + CRLF);
    parts.push(`--${boundary}--${CRLF}`);
    return { body: parts.join(''), boundary };
  }
  function httpPostMultipart(url, fieldName, filename, content, extraHeaders){
    const { body, boundary } = makeMultipartBody(fieldName, filename, content);
    const headers = Object.assign({ 'Content-Type': 'multipart/form-data; boundary='+boundary }, extraHeaders||{});
    return new Promise((resolve,reject)=>{
      GM_xmlhttpRequest({
        method:'POST',
        url,
        data: body,
        headers,
        onload: res => {
          console.log('[RDS3 AutoPush] POST status:', res.status);
          if (res.status>=200 && res.status<300) resolve(res);
          else reject(new Error(`HTTP ${res.status}: ${res.responseText||res.statusText}`));
        },
        onerror: e => reject(new Error(`Network error`))
      });
    });
  }

  /******** SCRAPER ********/
  function extractCourseData(){
    const cells = document.querySelectorAll(COURSE_CELL_SELECTOR);
    console.log('[RDS3 AutoPush] Found cells:', cells.length);
    const out = []; const seen = new Set();

    cells.forEach(cell=>{
      const onclickAttr = cell.getAttribute('onclick'); if(!onclickAttr) return;
      let params = [];
      try{
        const fn = new Function(`
          let params = [];
          const mockFunction = function(){ params = Array.from(arguments); };
          const addNewCourses = mockFunction;
          ${onclickAttr};
          return params;
        `);
        params = fn();
      }catch(e){
        console.warn('[RDS3 AutoPush] Param capture failed:', e);
        return;
      }
      if(!params || params.length<10) return;

      const courseCode = params[0];
      const section    = params[4];
      const roomId     = parseInt(params[5]);
      const timeId     = parseInt(params[6]);
      const faculty    = params[7];
      const totalSeat  = params[8];
      const takenSeat  = params[9];

      const key = `${courseCode}-${section}-${timeId}`;
      if(seen.has(key)) return; seen.add(key);

      const courseTime = resolveCourseTime(timeId);

      out.push({
        CourseCode: courseCode,
        Section: section,
        Faculty: faculty,
        CourseTime: courseTime,
        TotalSeat: totalSeat,
        TakenSeat: takenSeat,
        RoomId: roomId,
        Room: roomArr[roomId] || 'Unknown'
      });
    });

    console.log('[RDS3 AutoPush] Extracted courses:', out.length);
    return out;
  }

  async function runUpload(alwaysPostEvenIfEmpty = false){
    try{
      const courses = extractCourseData();
      const csv = convertToCSV(courses);
      const headers = RUN_KEY ? {'X-Run-Key': RUN_KEY} : {};

      if (courses.length == 0 ) {
        console.log('[RDS3 AutoPush] No courses; skipping upload');
        return;
      }

      await httpPostMultipart(`${API_BASE}/csv`, 'file', FILENAME, csv, headers);
      console.log('[RDS3 AutoPush] Upload complete');
    }catch(e){
      console.warn('[RDS3 AutoPush] Upload failed:', e.message || e);
    }
  }

  /******** ORCHESTRATION ********/
  function startObserver() {
    let ran = false;
    const stop = () => { try { obs.disconnect(); } catch {} };
    const obs = new MutationObserver(() => {
      const cells = document.querySelectorAll(COURSE_CELL_SELECTOR);
      if (!ran && cells.length > 0) {
        ran = true;
        stop();
        console.log('[RDS3 AutoPush] Cells detected by observer; uploading…');
        runUpload(true);
      }
    });
    obs.observe(document.documentElement || document.body, { childList: true, subtree: true });

    // Fallback: try once after a short delay even if cells not found (some pages still have time map ready)
    setTimeout(() => {
      if (!ran) {
        console.log('[RDS3 AutoPush] Fallback attempt after', pollFallbackMs, 'ms');
        runUpload(true);
      }
    }, pollFallbackMs);

    // Hard timeout: stop observing after waitTimeoutMs
    setTimeout(() => {
      if (!ran) {
        console.log('[RDS3 AutoPush] Timeout reached; stopping observer (no cells detected).');
      }
      stop();
    }, waitTimeoutMs);
  }

  /******** PART 2: FACULTY INITIAL + TIME DISPLAY ********/
  // Inject custom styles
  const style = document.createElement('style');
  style.innerHTML = `
    .courselist td:nth-child(3) {
      width: 180px !important;
      white-space: nowrap;
      overflow: visible;
      color: blue;
      font-weight: bold;
    }
    #mainBody {
      text-align: center !important;
      width: 1200px  !important;
      height: 900px !important;
    }
    #noticebar {
      width: 1190px !important;
    }
    #offeredCourses.body, #offeredCourses {
      width: 495px !important;
      height: 750px !important;
    }
    #coursesbox {
      width: 190% !important;
      height: 776px !important;
    }
    .right {
      width: 270px !important;
    }
    #topbar {
      width: 1190px !important;
    }
    #searchDiv {
      width: 350px !important;
    }
    .time-text {
      color: black !important;
      display: inline-block !important;
      vertical-align: middle !important;
      line-height: 1.4 !important;
    }
    #coursesbox {
      width: 505px !important;
    }
    #legendbox {
      position: relative !important;
      left: 130px !important;
      height: 115px !important;
    }
    img[src*="legend.png"] {
      padding: 10px !important;
      border: 2px solid black !important;
    }
    .courselist td {
      line-height: 23px !important;
      border-bottom: 2px solid #000 !important;
      font-size: 14px !important;
      cursor: pointer !important;
      font-weight: bold !important;
      padding: 0px !important;
      vertical-align: middle !important;
      width: 100% !important;
    }
    .abouticon.smallicon {
      display: none !important;
    }
  `;
  document.head.appendChild(style);

  function updateTimeText() {
    const icons = document.querySelectorAll('.abouticon');
    icons.forEach(icon => {
      const onclickStr = icon.getAttribute('onclick');
      if (!onclickStr) return;
      const paramsMatch = onclickStr.match(/displayToolTip\((.*)\)/);
      if (!paramsMatch) return;
      const paramsRaw = paramsMatch[1];
      const params = paramsRaw.split(',').map(p => p.trim().replace(/^['"]|['"]$/g, ''));
      const timeIndex = params[3];
      if (!timeIndex) return;

      // Use the same time resolution logic as the CSV extractor
      const courseTime = resolveCourseTime(timeIndex);
      if (courseTime === 'Unknown') return;

      let timeTextSpan = icon.parentElement.querySelector('.time-text');
      if (!timeTextSpan) {
        timeTextSpan = document.createElement('span');
        timeTextSpan.className = 'time-text';
        icon.parentElement.appendChild(timeTextSpan);
      }
      timeTextSpan.textContent = ' ' + courseTime;
    });
  }

  function updateCourseCodes() {
    const courseCells = document.querySelectorAll('table.courselist td:first-child');
    courseCells.forEach(cell => {
      const onclickStr = cell.getAttribute('onclick');
      if (!onclickStr) return;
      const params = onclickStr.match(/addNewCourses\(([^)]+)\)/);
      if (!params) return;
      const parts = params[1].split(',').map(p => p.trim().replace(/^['"]|['"]$/g, ''));
      const courseId = parts[0];
      const section = parts[4];
      const roomCode = parts[7];
      if (courseId && section && roomCode) {
        cell.textContent = `${courseId}.${section}.${roomCode}`;
      }
    });
  }

  function setupSearchFilter() {
    const searchInput = document.getElementById('searchText');
    if (!searchInput) return;
    searchInput.addEventListener('input', function () {
      const filter = this.value.toLowerCase().trim();
      const searchParts = filter.split(/\s+/);
      const rows = document.querySelectorAll('#courseList tr');
      rows.forEach(row => {
        const courseCell = row.querySelector('td:first-child');
        const timeSpan = row.querySelector('.time-text');
        if (!courseCell) return;
        const courseText = courseCell.textContent.toLowerCase();
        const timeText = timeSpan ? timeSpan.textContent.toLowerCase() : '';
        const combinedText = `${courseText} ${timeText}`;
        const allMatch = searchParts.every(part => combinedText.includes(part));
        row.style.display = allMatch ? '' : 'none';
      });
    });
  }

  /******** COMBINED INITIALIZATION ********/
  function initializeCombinedScript() {
    // Part 1: Start auto-push observer
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', startObserver, { once: true });
    } else {
      startObserver();
    }

    // Part 2: Initialize UI enhancements
    updateTimeText();
    updateCourseCodes();
    setupSearchFilter();
  }

  // Start the combined script
  initializeCombinedScript();

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址