High School Sports Schedules in Plain Text

A tool for grabbing High School sports schedules on all the school sites (VNN) and also maxpreps.com and sports.deseret.com, and converting them into a simple plain text format for easy compilation and printing. By StevenRoy

// ==UserScript==
// @name        High School Sports Schedules in Plain Text
// @namespace   http://michrev.com/
// @description A tool for grabbing High School sports schedules on all the school sites (VNN) and also maxpreps.com and sports.deseret.com, and converting them into a simple plain text format for easy compilation and printing. By StevenRoy
// @include     *schedule*
// @include     *maxpreps.com/*
// @include     *sports.deseret.com/*
// @version     0.26
// @grant       none
// @run-at      document-start
// @supportURL  https://greasyfork.org/en/users/934871
// ==/UserScript==

"use strict";

//     _____________  __    __
//    / ___________/ /  \,-' /
//   / /__    ___   / /\,-/ / StevenRoy was here
//   \___ \  / __\ / /   / /
//______/ / / /   / /   / /   02023.10.20
//_______/ /_/   /_/   /_/

	window.addEventListener('beforescriptexecute', killtheadmiral); // ,true (?)
if (top===self /*&& top.document.constructor=== HTMLDocument*/) { // weird things happen when loading images or PDF files?
	document.addEventListener("DOMContentLoaded", function(){
		console.log("We got DOMContentLoaded - readyState:",document.readyState);
//    document.removeEventListener("DOMContentLoaded", arguments.callee, false); // Required? I doubt it!
		performTheMagic(document);
	},false);
} else { console.log('Frame did a weird thing',top,"!==",self); }

function performTheMagic(D){
	if (top.location.href.match(/\/{2}[w.]{0,4}maxpreps\.com\/print\/schedule/i)) { return mpprintmode(D); }
	if (top.location.href.match(/\/{2}[w.]{0,4}maxpreps\.com/i)) { return waitfornextthing('MCS/mpmode',mpmode); }
	if (top.location.href.match(/\/{2}[w.]{0,4}sports\.deseret\.com/i)) { return waitfornextthing('MCS/dnmode',dnmode); }
	var t=D.location.pathname.match(/schedule/i);
	if (!(t && t.length)) return;
//	t=D.getElementsByClassName("gFMfPS");
	t=D.getElementById("event-list-table");
	if (t) { return vnnmode(D,t); }
	console.log('MCS: Found schedule page without schedule');
}

function tcboo(l,f,e) { // there can be only one
	if (l && l.length==1) { return f(l[0]); }
	else if (e) return e(l); // failure function
}

function trims(s) { return s.trim().replace(/ {2,}/g," "); }

var plink;
// TODO: better template format, user-configurable templates
const _rrr={ // the raw row renderer
	thisteam:'', // NOTE: reset() does not clear this one!
	reset() { this.d='TBD'; this.t='TBD'; this.ht=false; this.vt=false; this.ot=false; this.away=false; this.extra=''; },
//	template='date time @o'; // Fri Aug 27 7:00 PM @ Granger
	_template:'dxvt', // date, visitor@home team, time
	render() {
		return trims(this._template.split('').map((i)=>{
			switch(i){
				case 'd':
					return this.d; // "Fri Aug 20"
				case 't':
					return this.t; // "7:00 PM"
				case 'v':
					if (!(this.vt && this.ht)) {
						if (!(this.ot && this.thisteam)) throw ("RRR/v without teams set");
						if (this.away) { this.vt=this.thisteam; this.ht=this.ot; }
						else { this.ht=this.thisteam; this.vt=this.ot; }
					}
					return this.vt+" @ "+this.ht; // visitor @ home teams
				case 'o':
					if (!(this.ot)) {
						if (!(this.vt && this.ht && this.thisteam)) throw ("RRR/o without teams set");
						this.away=this.vt==this.thisteam;
						this.ot=this.away?this.ht:this.vt;
					}
					return (this.away?"":"@ ")+this.ot;
//				o += d+(r.opponentTeam.homeAwayType?" ":" @ ")+r.opponentTeam.name+"\n";

				case 'x':
					return ("extra" in this) ? this.extra : "";

				case ',':
				case ':':
				case '-':
					return i; // a literal character
				default:
					throw("RRR Template broke at character "+i);
			}
		}).join(' ').replace(/ [,:-]/g,",")); // no spaces before punctuation
	}
};

function hideparalink(){
	if (!plink) return;
	plink.style.display="none";
	plink.innerHTML='';
}

function createparalink(t,cb){ // TODO: Rethink this UI, make a menu or something
	if (!plink) {
		plink = document.createElement('a');
		plink.setAttribute('href', '#');
		plink.setAttribute('style', 'position:fixed; text-align:center; bottom:0px; right:0px; z-index:9099998;'+
		'border:3px solid #789; box-sizing:border-box; font-family: Courier New; font-weight: 700;' +
		'background:#bcd; margin:0; padding:6px; text-decoration:none; color:#456; font-size:15pt; line-height:14pt');

		document.body.appendChild(plink);
	}
	plink.innerHTML=t;
	plink.style.display="block";
	plink.onclick=(e)=>{
		if (!e) e=window.event;
		if (e.preventDefault) e.preventDefault();
		try { cb(); }
		catch (X) { window.alert("Well, that didn't work...\nInstead we got this:\n\n"+X); }
	};
}

function setClipboard(text,forcefallback) { // TODO: Test this in Vivaldi, it might be broken! (a Chromium quirk perhaps?)
	log("MCS: Setting clipboard"+(forcefallback?" (fallback)":""),text);
	if (navigator.clipboard && navigator.clipboard.writeText && !forcefallback) { // Supported anywhere? Might remove.
		try {
			navigator.clipboard.writeText(text).then(function() {
				log('Async: Copying to clipboard was successful!');
				window.alert("The following text has been copied to your clipboard:\n\n"+text);
			}, function(err) {
				console.error('Async: Could not copy text: ', err);
				setClipboard(text,1);
			});
		} catch (err) {
			console.error('Sync: Could not copy text: ', err);
			setClipboard(text,1);
		}
		return;
	}
	var textArea = document.createElement("textarea");
	textArea.value = text;
	textArea.style.position="fixed";// Avoid scrolling to bottom
	textArea.style.top = "0";
	textArea.style.left = "-9000px";
//	textArea.style.display= "none"; // Does this cause it to not work? Weird.
	document.body.appendChild(textArea); // Necessary too? Probably is!
	textArea.focus();
	textArea.select();

	try {
		var successful = document.execCommand('copy');
		var msg = successful ? 'successful' : 'unsuccessful';
		log('Fallback: Copying text command was ' + msg);
		if (successful) { window.alert("The following text has been copied to your clipboard:\n\n"+text); }
	} catch (err) {
		console.error('Fallback: Oops, unable to copy', err);
	}
	document.body.removeChild(textArea);
}



function vnnmode(D,E){ // this covers most high school sites
	var rows=Array.from(E.getElementsByClassName("event")).filter(e=>!e.classList.contains('print-hide'));
	if (!rows.length) return;

// How to determine the current school name?
// Delving into all the "meta" tags could work but could it get messy?
// For example: <meta property="og:description" content="Davis High School"/>
tcboo(Array.from(document.head.getElementsByTagName("meta")).filter(n=>
n.hasAttribute("property")&&n.hasAttribute("content")&&n.getAttribute("property").match(/^og:description$/i)),n=>{
	_rrr.thisteam=n.getAttribute("content").replace(/ +high school.*$/i,'');
},e=>{
	_rrr.thisteam="(TEAM)";
}); // That wasn't so bad...?

	createparalink("Copy schedule<br />("+rows.length+" rows)<br />to clipboard",()=>{
		var o=''; // Output line: Tue, 11/24 7:00 PM Box Elder
		log('MCS: VNN mode. Rows:',rows);
		rows.forEach(e=>{
//			log('MCS: Row',e);
			tcboo(e.getElementsByClassName('date print-hide'),(tds)=>{
				_rrr.reset();
//				tds=tds[0]; o+=trims(tds.textContent).replace(/,/g,'');
// worse: <td class="date print-show" style="display:none;">    Fri, Dec 4      7:00 pm  </td>
// better: <td class="date print-hide pl-3 date-mobile" data-event-date="Mon, Nov 20" data-event-start-time=" 7:00 pm">
				let ta= tds.getAttribute("data-event-date")||"";
				ta=trims(ta).replace(/,/g,'');
				if (ta) _rrr.d=ta;
				ta= tds.getAttribute("data-event-start-time")||"";
				ta=trims(ta).replace(/,/g,'');
				if (ta) _rrr.t=ta;
// TODO: Might it eventually become really useful to parse that into a proper
// Date object so that it can be rendered by RRR with more template options?

				tcboo(e.getElementsByClassName('home-away'),(tds)=>{
					tds=trims(tds.textContent).toUpperCase();
					_rrr.away= (tds!='HOME'); // o += ' @';
				});

				tcboo(e.getElementsByClassName('opponent'),(tds)=>{
					_rrr.ot = trims(tds.textContent.replace(/ high school/i,''));
					tcboo(tds.parentNode.getElementsByClassName('event-name'),(tds)=>{
						tds=trims(tds.textContent); if (tds && (tds !== '-')) { _rrr.extra = ' '+tds; }
					});
				});
				o += _rrr.render()+"\n";
			});
		});
		if (o) { setClipboard(o); }
	});
}

/*
If they're going to keep changing the class names, we can't use them!
BUT the printable pages don't use the random names; should we try to load those instead?
https:// www.maxpreps.com/print/schedule.aspx?schoolid= # &ssid= # &print=1
How to extract schoolid/"teamId" and ssid/"sportSeasonId" from current page?

JSON.stringify(__NEXT_DATA__.props.pageProps.schedule[1])
And from all that, we need something that looks like: Fri 12/4 7:00 PM Pleasant Grove
__NEXT_DATA__.props.pageProps.schedule[0].isDeleted == true  *** WATCH OUT FOR "DELETED" ROWS!
*/

function mpmode(r){ // the maxpreps site
//	var r=window.__NEXT_DATA__;
//	if (!r) return log("__NEXT_DATA__ not found?");
//	r=r.props;
//	if (r) r=r.pageProps;
	if (r) r=r.schedule || r.contests; // A recent change?
	if (!(r && r.length)) { hideparalink(); return log("Schedule not found"); }
	r=r.filter(x=>!(x.isDeleted || x.opponentTeam.isTeamTBA || x.isDateTba)); // Gotta watch out for this!

	createparalink("Copy schedule<br />("+r.length+" rows)<br />to clipboard",()=>{
enterlogsection('MCS/mpmode',()=>{

		var o='';
		r.forEach((r)=>{
			_rrr.reset();
			var d=r.isDateTba||r.isTimeTba;
			if (d) { d="TBD"; } else {
				d=new Date(r.date); // "2022-08-12T19:00:00" -> 8/12 7:00pm
				_rrr.d=d.toDateString().replace(/ +[0-9]{4}$/,''); // "Fri Dec 04 2020"
				_rrr.t=d.toLocaleTimeString('en',{hour:'numeric',minute:'2-digit'}); // "7:00 PM"
			}
			if (r.currentTeam && !_rrr.thisteam) { _rrr.thisteam=r.currentTeam.name; }
			if (!r.opponentTeam) { return log('No opponent team?',r); } // Skip this if no team info
//			if (r.opponentTeam.isTeamTBA) {
//				o += d+(r.opponentTeam.homeAwayType?" ":" @ ")+"TBA\n";
//			} else {
				_rrr.away=!r.opponentTeam.homeAwayType;
//				if (!r.opponentTeam.name) { return log('Opponent team has no name?',r); }
				_rrr.ot=r.opponentTeam.name || "(No name?)";
//				o += d+(r.opponentTeam.homeAwayType?" ":" @ ")+r.opponentTeam.name+"\n";
			if (r.location) { _rrr.extra=r.location; } // Extra game info, not always location?
				o += _rrr.render()+"\n";
//			}
		});

/* THE OLD WAY and guess how often this breaks and why...
		t=E.getElementsByClassName('kefwvP');
		if (t && t.length) { Array.from(t).forEach(r=>{ // The "featured" game(s?)
			console.log("MCS/mpmode: processing featured:",r);
			to='';
			tt=r.getElementsByClassName('foHRy');
			if (!(tt && tt.length==1)) { return; } // skip this row if we don't find date
			to=trims(tt[0].textContent).replace(/,/g,'');
			tt=r.getElementsByClassName('dfAURx');
			if (!(tt && tt.length==1)) { return; } // skip this row if we don't find time
			to+=" "+trims(tt[0].textContent);
			tt=r.getElementsByClassName('homeAway');
			if (!(tt && tt.length==1)) { return; } // skip this row if we don't find home/away
			tt=trims(tt[0].textContent);
			if (tt.match(/^away/i)) to += ' @';
			tt=r.getElementsByClassName('hdmKYN');
			if (!(tt && tt.length==2)) { return; } // didn't find opponent team name
			o+=to+' '+trims(tt[1].textContent)+"\n"; // opponent team name ...and then we done!
		}); } else console.log("MCS/mpmode: no featured?",t);

		t=E.getElementsByClassName('guknjX');
		if (t && t.length) { Array.from(t).forEach(r=>{ // The other games
			console.log("MCS/mpmode: processing row:",r);
			to=trims(r.textContent).replace(/,/g,'');
			t=r.nextSibling;
			if (!t) { console.log("MCS/mpmode: Borked row, date but no time?",r,to); return; }
			to += ' '+trims(t.textContent);
			r=r.parentNode.parentNode; // this should point to the tr now
			tt=r.getElementsByClassName('cKEqht');
			if (!(tt && tt.length==1)) { console.log("MCS/mpmode: no home/away"); return; } // didn't find home/away
			tt=trims(tt[0].textContent);
			if (tt.match(/^away/i)) to += ' @';
			tt=r.getElementsByClassName('align-x'); // They don't even use their random class names consistently!?
			if (!(tt && tt.length==1)) { console.log("MCS/mpmode: no align-x"); return; }
			tt=tt[0].children; // But at least we can (apparently) get to it this way...
			if (!(tt && tt.length>=1)) { console.log("MCS/mpmode: no name element"); return; }
			o+=to+' '+trims(tt[0].textContent.replace(/ high school/i,''))+"\n"; // Replace probably unnecessary here?
		}); } else console.log("MCS/mpmode: no rows?",t);
*/
		if (o) { setClipboard(o); }
});
	});
}

function mpprintmode(D) { // schedule print page - VERY different!
	var r=D.getElementById("schedule");
	if (r) r=r.getElementsByClassName('dual-contest');
	if (!(r && r.length)) return log("Schedule not found",r);

	createparalink("Copy schedule<br />("+r.length+" rows)<br />to clipboard",()=>{
enterlogsection('MPPrint',()=>{

		var o='';
		if (utag_data && utag_data.schoolName) {
			_rrr.thisteam=utag_data.schoolName.toUpperCase().replace(/ \(.*\)$/,"")
				.replace(/([A-Z])([A-Z]+)/g,(m,a,b)=>{return a+b.toLowerCase(); }); // Mixed case (because page does toLowerCase() to it)
		}
		Array.from(r).forEach((r)=>{
			_rrr.reset();
/*			var d=r.isDateTba||r.isTimeTba;
			if (d) { d="TBD"; } else { */
			var t,d=r.getElementsByClassName('event-time');
			if (d && d[0] && d[0].textContent.toUpperCase()!='TBD') {
				t=new Date(d[0].getAttribute("title")+".000Z"); // "2022-08-13T02:00:00" -> 8/12 7:00pm ???
				_rrr.d=t.toDateString().replace(/ +[0-9]{4}$/,''); // "Fri Dec 04 2020"
//				_rrr.t=t.toLocaleTimeString('en',{timeStyle:'short',hour:'numeric',minute:'2-digit'}); // "7:00 PM"
// information here seems to be in the wrong TZ? (we get 8 PM instead of 7) So instead we just...
				_rrr.t=d[0].innerText; // "7:00p", assume correct time zone in the plain text
// (And it's worse if we don't add the ".000Z" above. Never had to do that before.)
// TODO: Parsing event-date seems potentially easier. Getting those numbers into a Date object less so though.
			}

//			if (!(r.home && r.home.team)) { return log('No home team?',r); } // Skip this if no team info
//			if (!(r.visitor && r.visitor.team)) { return log('No home team?',r); } // Skip this if no team info
//			_rrr.ht=r.home.team.name;
//			_rrr.vt=r.visitor.team.name;
			t=r.getElementsByClassName('contest-name');
			if (t && t[0]) {
				t=t[0].innerText.replace(/ \(.*\)$/,"");
				if (t.match(/^@ /)) {
					_rrr.away=true;
					t=t.substr(2);
				}
				_rrr.ot=t; // opposing team
			}
			t=r.getElementsByClassName('contest-location');
			if (t && t[0]) {
				t=t[0].textContent.match(/^Game Details: (.+)$/);
				if (t && t[1]) _rrr.extra=t[1];
			}
			o += _rrr.render()+"\n";
		});

		if (o) { setClipboard(o); }
});
	});

}

/*
Very surprisingly, Deseret News also uses __NEXT_DATA__ ...Though in a different way so we need a different parser.
*/

function dnmode(r){
//	var r=window.__NEXT_DATA__;
//	if (!r) return log("__NEXT_DATA__ not found?");
//	r=r.props;
//	if (r) r=r.pageProps;
	if (r) {
		if (r.team && r.team.name) { _rrr.thisteam= r.team.name; } // TODO: Implement this in the other modes?
		r=r.previousMatchupGames;
	}
	if (!(r && r.length)) return log("Schedule not found",r);
//	r=r.filter(x=>!(x.isDeleted || x.opponentTeam.isTeamTBA || x.isDateTba)); // Gotta watch out for this?

	createparalink("Copy schedule<br />("+r.length+" rows)<br />to clipboard",()=>{
enterlogsection('MCS/dnmode',()=>{

		var o='';
		r.forEach((r)=>{
			_rrr.reset();
/*			var d=r.isDateTba||r.isTimeTba;
			if (d) { d="TBD"; } else { */
				var d=new Date(r.date);
				_rrr.d=d.toDateString().replace(/ +[0-9]{4}$/,''); // "Fri Dec 04 2020"
				_rrr.t=d.toLocaleTimeString('en',{hour:'numeric',minute:'2-digit'}); // "7:00 PM"
//			}

			if (!(r.home && r.home.team)) { return log('No home team?',r); } // Skip this if no team info
			if (!(r.visitor && r.visitor.team)) { return log('No home team?',r); } // Skip this if no team info

			_rrr.ht=r.home.team.name;
			_rrr.vt=r.visitor.team.name;
			if (r.tournament) _rrr.extra=r.tournament;

			o += _rrr.render()+"\n";
		});

		if (o) { setClipboard(o); }
});
	});
}

var logsection='';
function enterlogsection(n,c,p){
	var ols=logsection;
	log('ENTERING SECTION:',n);
	if (ols==='') { logsection=n; } else { logsection+="/"+n; }
	try { if (p) { c(p); } else { c(); } }
	catch (X) { console.error(logsection+" EXCEPTION: ",X); }
	logsection=ols;
	log('LEFT SECTION:',n);
}
function log() {
	console.log(logsection,...arguments);
}
function retry(count,condfunc,delay,scb,fcb,param) {
	if (count<1) return fcb&&fcb(param); // failed
	if (condfunc(param)) return scb&&scb(param); // condition returns true to indicate success
	return window.setTimeout(()=>{ retry(count-1,condfunc,delay,scb,fcb,param); },delay);
}
function waitfornextthing(lsname,func){
	retry(20,(p)=>{ // conditional
		var e;
		p.c++;
		if (e=window.__NEXT_DATA__) {
			if (e.props && e.props.pageProps) { p.e=e.props.pageProps; return true; } // success
			p.cn++;
		}
		return false;
	},500,(p)=>{
		log("After "+p.c+" pageProps searches and "+p.cn+" invalid results...");
		enterlogsection(lsname,func,p.e);
		var lasturl=top.location.href;
		next.router.events.on('routeChangeStart',(u)=>{ // OH THANK GOODNESS this exists and is usable...
			if (u!=lasturl) {
//				window.alert("Going to:\n"+u);
				lasturl=u; hideparalink();
// When a different page is navigated to, new __NEXT_DATA__ is read by the site JS but not stored in an
// accessible variable, so we need to force a full page load every time. SO ANNOYING!
				if (u.match(/schedule\./i)) { top.location.replace(u); return; } // A cheap workaround but it works!
			}
		});
	},(p)=>{
		log("FAILED After "+p.c+" pageProps searches and "+p.cn+" invalid results.");
	},{c:0,cn:0});
}

function killtheadmiral(e) {
	if (e.target.src!="") return;
	var et=e.target.innerText;
	function yeskillit() { e.preventDefault(); e.stopPropagation(); console.log('*zzzap!*'); }

//	console.log("Checking:",et);

// This is a thing on MaxPreps site that makes it obnoxious.
// (Checking for it on all sites is probably overkill - but that's better than underkill.)
	if (et.match(/cbsoptanon/)) {
		console.log('Killed an Admiral script');
		return yeskillit(); // *zzzap!*
	}
	if (et.replace(/[ \t\n\r]+/g,'')=="window.print();") { // Maxpreps print page doesn't need this!
		console.log('Killed automatic-printing script');
//		window.print=function(){ throw(new ReferenceError("Intercepted")); };
		return yeskillit(); // *zzzap!*
	}
}