// ==UserScript== // @name WU LPIS Registration Bot // @namespace https://www.egimoto.com // @version 1.1 // @description Register with ease // @author PreyMa // @match https://lpis.wu.ac.at/* // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgBAMAAACBVGfHAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAASUExURQAAAH9/f9PT0+0cJO/v7////4KSIg8AAACKSURBVCjPhdFRDoAgCABQr4DhAewGjXUANi/QR/e/SoqkVLr40HyDZOhSDSL9cLrvb6BfYDQAAMjIeVNYiAoQbRMAEwJ+bREVlGKDnJuPeYmzEsmwEM7jWRKcBdYMKcEK/R8FQG6JdYURsO0DR9A7Bf9qPXjTeokOQWMO9wD9ZB6fIcvD1VdKA7gAUO5YI8LDmx0AAAAASUVORK5CYII= // @grant GM.getValue // @grant GM.setValue // @grant GM.openInTab // @noframes // ==/UserScript== (async function() { 'use strict'; const Color= { ErrorBox: '#ff6969', ErrorBoxBorder: '#ff0000', ActiveRow: '#90ee90', HoveredRow: '#acf1cd', ActiveSubmitButton: '#5eff41', Pending: 'yellow' }; /** * @typedef {{name:string, text:string, color:string}} State * @type {{Ready:State, Error:State, Pending:State, Starting:State, Selecting:State}} */ const State= { Ready: {name: 'Ready', text: '๐Ÿ‘“ Ready', color: 'lightgreen'}, Error: {name: 'Error', text: 'โŒ Error!', color: Color.ErrorBox}, Pending: {name: 'Pending', text: 'โณ Pending...', color: Color.Pending}, Starting: {name: 'Starting', text: '๐Ÿƒโ€โ™‚๏ธ Starting...', color: Color.Pending}, Selecting: {name: 'Selecting', text: '๐Ÿ‘† Selecting...', color: Color.HoveredRow} } /** * @typedef {{name:string, text:string, color:string}} ClientStatus * @type {{Disconnected:ClientStatus, Error:ClientStatus, Pending:ClientStatus, Done:ClientStatus}} */ const ClientStatus= { Disconnected: {name: 'Disconnected', text: '๐Ÿ“ก Disconnected', color: 'lightgrey'}, Error: {name: 'Error', text: 'โŒ Error!', color: Color.ErrorBox}, Pending: {name: 'Pending', text: 'โณ Pending...', color: Color.Pending}, Done: {name: 'Done', text: '๐Ÿ‘ Done', color: 'lightgreen'}, } const ButtonMode= { Register: { name: 'Register', before: 'anmelden', after: 'abmelden' } } const Style= { Clock: { container: { fontSize: '2rem', fontFamily: 'Consolas, monospace', display: 'flex', justifyContent: 'center' }, frame: { whiteSpace: 'pre', border: '7px grey double', borderRadius: '0.4em', padding: '1rem', width: 'max-content' } }, Table: { 'table.schedule td, table.schedule th': { padding: '0.3rem' }, 'table.schedule tr': { borderBottom: '1px solid grey' }, 'table.schedule tr:last-child': { borderBottom: 'none' } }, mainContainer: { display: 'flex', flexDirection: 'column', gap: '1rem', margin: '1rem', padding: '1rem', boxShadow: '3px 3px 5px 2px #adadad', borderRadius: '0.5rem' }, topBar: { display: 'flex', flexDirection: 'row', gap: '1rem' }, messageField: { border: '1px solid grey', padding: '1rem', fontStyle: 'italic', display: 'none', borderRadius: '5px', animation: 'wu-bot-moving-gradient linear 2s infinite' } }; function println( ...args ) { console.log( '[WU LPIS Registration Bot]', ...args ); } /** * @typedef {{name:string, prefix:string}} TraceMode * @type {{Inbound:TraceMode, Outbound:TraceMode, ResponseIn:TraceMode, ResponseOut:TraceMode}} */ const TraceMode= { Inbound: {name: 'Inbound', prefix: 'โ†’ Trace'}, Outbound: {name: 'Outbound', prefix: 'โ† Trace'}, ResponseIn: {name: 'ResponseIn', prefix: 'โ†’ Trace (Response)'}, ResponseOut: {name: 'ResponseOut', prefix: 'โ† Trace (Response)'} } const doTracing= false; /** * Optionally print a trace message for a channel message packet * @param {TraceMode} mode * @param {ChannelMessage} packet * @param {string} info */ function tracePacket(mode, packet, info= '') { if( doTracing ) { console.log(mode.prefix, info, packet, new Error()); } } /** * @returns {never} */ function abstractMethod() { throw Error('abstract method'); } function assert(cond, msg= 'Assertion failed') { if(!cond) { throw Error(msg); } } /** * Creates a promise that resolves after the specified number of milliseconds * @param {number} millis * @returns {Promise} */ async function asyncSleep(millis) { return new Promise( resolve => { window.setTimeout(() => resolve(), millis); }); } /** * Returns the anchor element containing 'Einzelanmeldung' * @returns {HTMLAnchorElement|null} */ function extractNavbarFirstItem() { return document.querySelector('body > a[title="PRF/LVP/PI-Anmeldung"]'); } let mainTableElement= null; /** * Searches for the main table listing all the LVAs * @returns {HTMLTableElement|null} */ function mainTable() { if( mainTableElement ) { return mainTableElement; } const tables= document.querySelectorAll('table'); for( const table of tables ) { try { const headerColumns= table.tHead.firstElementChild.children; if( !headerColumns || headerColumns.length !== 3 ) { break; } const veranstaltungText= headerColumns.item(0).innerText.trim().toLowerCase(); const plaetzeText= headerColumns.item(1).innerText.trim().toLowerCase(); if( veranstaltungText !== 'veranstaltung' || plaetzeText !== 'plรคtze' ) { break; } mainTableElement= table; return table; } catch( e ) {} } return null; } /** * Finds the table row for a LVA by its id shown in the first table cell * @param {string} id * @returns {HTMLTableRowElement|null} */ function findLvaRowById( id ) { // Skip the inside the by starting with index 1 const rows= mainTable().rows; for( let i= 1; i< rows.length; i++ ) { const row= rows.item( i ); if( id === extractLvaIdFromRow( row ) ) { return row; } } return null; } /** * The id of a LVA by its table row element * @param {HTMLTableRowElement} row * @returns {string} */ function extractLvaIdFromRow( row ) { return row.firstElementChild.innerText.split('\n')[0].trim(); } /** * Searches for a submit element in a LVA table row * @param {HTMLTableRowElement} row * @returns {HTMLButtonElement|HTMLAnchorElement|null} */ function extractSubmitButtonFromRow( row ) { return row.querySelector('td.action form input[type="submit"]') || row.querySelector('td.action a'); } /** * Searches for and parses the registration start date of a LVA by its row * @param {HTMLTableRowElement} row * @returns {Date|null} */ function extractDateFromRow( row ) { const text= row.querySelector('td.action .timestamp').innerText.trim(); if( !/^\w+\s+\d{1,2}\.\d{1,2}\.\d{4}\s+\d{1,2}:\d{1,2}$/gm.test( text ) ) { console.error(`Regex check failed`); return null; } const parts= text.split(/\s/); if( parts[0] !== 'ab' && parts[0] !== 'bis' ) { console.error(`Expected 'ab' or 'bis' before date string`); return null; } if( parts.length < 3 ) { console.error('Too little parts to parse date'); return null; } const dateParts= parts[1].split('.'); const timeParts= parts[2].split(':'); return new Date( parseInt( dateParts[2] ), // year parseInt( dateParts[1] ) -1, // month (zero based) parseInt( dateParts[0] ), // days parseInt( timeParts[0] ), // hours parseInt( timeParts[1] ), // minutes 0, // seconds 0 // millis ); } /** * Check if the provided submit (button) element has the expected state * @param {Settings} settings * @param {HTMLAnchorElement|HTMLButtonElement} submitButton * @param {boolean} useAfterText * @returns {boolean} */ function checkSubmitButton(settings, submitButton, useAfterText= false) { const buttonText= submitButton.value || submitButton.innerText; const expectedText= useAfterText ? settings.buttonMode().after : settings.buttonMode().before; return buttonText.trim().toLowerCase() === expectedText; } /** * Creates an up-to-date URL to a registration page by its id. The current * URL is taken and the SPP query param is modified to create the new URL * @param {string|number} pageId * @returns {URL} */ function urlToPageId( pageId ) { // LPIS requires the SPP values to be in the same place every time, // else the server returns an error page. For more info see 'currentPageId()' const url= new URL(window.location); url.search= url.search.replace(/SPP=\w+(;?)/, `SPP=${pageId}$1`); return url; } let cachedPageId= null; /** * Reads and caches the page id of the current page * @returns {string|null} */ function currentPageId() { if( cachedPageId ) { return cachedPageId; } // LPIS stores its query parameters separated by semicolons instead of // ampersand which therefore prevents the use 'url.searchParams'. Instead // it is back to custom regex const match= window.location.search.match(/SPP=(?\w+);?/); if( !match || !match.groups.spp ) { return null; } return cachedPageId= match.groups.spp; } /** * Creates an HTML element by its node name and sets attributes & style * Child elements are automatically added in order * @param {string} type * @param {{}} attributes * @param {{}} style * @param {...HTMLElement|UIElement|string} children * @returns {HTMLElement} */ function createStyledElement( type, attributes, style, ...children ) { const element= document.createElement( type ); Object.assign( element.style, style ); children.forEach( c => { if( typeof c === 'string' ) { element.appendChild( document.createTextNode(c) ); return; } if( c instanceof UIElement ) { element.appendChild( c.getRoot() ); return; } element.appendChild( c ); }); for( const attr in attributes ) { element.setAttribute( attr, attributes[attr] ); } return element; } /** * Creates a
element with the interface of 'createStyledElement(...)' * @param {...HTMLElement|UIElement|string} children * @returns {HTMLDivElement} */ function div(attributes= {}, style= {}, ...children) { return createStyledElement( 'div', attributes, style, ...children ); } /** * Creates a element with the interface of 'createStyledElement(...)' * @param {...HTMLElement|UIElement|string} children * @returns {HTMLInputElement} */ function input(attributes= {}, style= {}, ...children) { return createStyledElement( 'input', attributes, style, ...children ); } /** * Creates a