DanMan / Steam Play Community Rating Notice

// ==UserScript==
// @name         Steam Play Community Rating Notice
// @version      2.5
// @description  Show Steam games' Proton compatibility user rating on supported websites.
// @author       DanMan
// @match        *://store.steampowered.com/app/*
// @match        *://steamdb.info/app/*
// @match        *://pcgamingwiki.com/wiki/*
// @match        *://www.fanatical.com/*
// @match        *://isthereanydeal.com/*
// @match        *://lutris.net/games/*
// @connect      protondb.max-p.me
// @grant        GM.xmlHttpRequest
// @grant        GM_xmlhttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// @copyright    2018, DanMan (https://openuserjs.org/users/DanMan)
// @supportURL   https://openuserjs.org/scripts/DanMan/Steam_Play_Community_Rating_Notice/issues
// @downloadURL  https://openuserjs.org/install/DanMan/Steam_Play_Community_Rating_Notice.user.js
// @updateURL    https://openuserjs.org/meta/DanMan/Steam_Play_Community_Rating_Notice.meta.js
// @require      https://openuserjs.org/src/libs/sizzle/GM_config.js
// ==/UserScript==

(function(){
	const MS2DAY = 86400000;
	var matches, injectors={}, config = {}, cache = {},
		memo = JSON.parse(sessionStorage.getItem('spcrn')) || {},
		img = 'https://support.steampowered.com/images/custom/platform_steamplay.png',
		frame = document.createElement('div'), cfgFields = {
			'gpuDriver':{ // label field first = before input element, otherwise after
				'label': 'GPU driver', 'type': 'text', 'default': '',
				'section': ['Only test results that match these filters will be considered',
				            'Text is case insensitive. You can use regular expressions.']
			},
			'protonVersion':{ 'label': 'Proton version', 'type': 'text', 'default': '' },
			'os':{ 'label': 'Operating system', 'type': 'text', 'default': '' },
			'timestamp':{ 'label': 'Max. test age', 'type': 'int', 'default': 0, 'title':'In days' }
		};

	GM_config.init({
		'id': 'spcrnFilters', 'title': 'Result filters', 'frame': frame, 'fields': cfgFields
		,'css':
			'#spcrnFilters { width:550px !important; height:auto !important; right:0 !important; left:0 !important; margin:auto !important; }'+
			'#spcrnFilters_wrapper { padding:1em; color:#333; }'+
			'#spcrnFilters .field_label { display:inline-block; line-height:30px; min-width:105px; }'
		,'events': {
			'save': function(){ sessionStorage.removeItem('spcrn'); },
			'open': function(doc, win, frm){
				let ageField = frm.querySelector('#spcrnFilters_field_timestamp');
				let tstamp = GM_config.get('timestamp'); //seconds
				if(ageField.value !=0 && tstamp >0){
					let diff = Date.now()-(tstamp * 1000);
					ageField.value = Math.floor(diff/MS2DAY);
				}
			}, 'close': daysToSeconds, 'init': daysToSeconds
		}
	});

	function daysToSeconds(){
		let days = GM_config.get('timestamp'), stamp;
		if(days < 36500){
			stamp = (Date.now()/1000)-(days*24*3600);
			GM_config.set('timestamp', Math.floor(stamp)); //seconds
		}
	}

	function inject(appId, rating){
		if(document.getElementById('spcrn')===null)
			injectors[document.location.hostname](appId,rating);
	}

	function createButton(){
		let node = document.createElement('span');
		node.textContent='⚙';
		node.setAttribute('title', 'Configure');
		node.setAttribute('style', [
			'font-size: 16px',
			'padding: 0 4px',
			'cursor: pointer'
		].join(';'));
		node.addEventListener('click', function(){ GM_config.open() }, false);
		document.body.appendChild(frame);
		return node;
	}

	function createBadge(rating,appId){
		let node = document.createElement('a'), lookup, title,
		    has_filter=('filters' in config);
		if(rating[1]>0 && rating[2].length>0){
			let lookup = Object.assign(...rating[2]);
			title = 'Most frequent' + (has_filter?', filtered':'');
			title+=' rating ('+lookup[rating[0]]+' out of '+rating[1]+' overall)';
		} else
			title = (rating[1] == 1)? "The only rating" : "No ratings "+(
				has_filter? 'apply' : 'yet'
			);

		node.textContent = rating[0].toUpperCase();
		node.setAttribute('title', title);
		node.setAttribute('href', 'https://www.protondb.com/app/'+appId);
		node.setAttribute('id', 'spcrn');
		node.setAttribute('style', [
			'background: #4d4b49 url('+img+') no-repeat -51px center',
			'display:inline-block',
			'line-height: 19px',
			'color: #b0aeac',
			'font-size: 10px',
			'padding: 3px 10px 0 79px',
			'margin-right: 1ex',
			'vertical-align:bottom',
			'font-family: "Motiva Sans", sans-serif'
		].join(';'));
		return node;
	}

	injectors['store.steampowered.com']= function(appId, rating){
		let node, target = document.querySelector('.game_area_purchase_platform');
		if(target){
			node = document.createElement('span');
			node.style.verticalAlign='bottom';
			node.appendChild(createButton());
			node.appendChild(createBadge(rating,appId));
			target.insertBefore(node,target.firstChild);
		}
	}
	injectors['steamdb.info']= function(appId, rating){
		let node = document.createElement('tr'), sorted=[], sum=0,
		    has_filter = ('filters' in config),
		    target = document.querySelector('.app-row table.table tbody'),
		    content = '<td>Steam Play Community Rating</td>';
	 		if(has_filter) content=content.replace('</td>','*</td>')
		if(target){
			if(rating[2].length>0){
				sorted = rating[2].map((obj) => Object.keys(obj)[0]+': '+Object.values(obj)[0] );
				sum = rating[2].map((obj) =>Object.values(obj)[0]).reduce((a,b) => a+b, 0);
			}
			content += '<td><a href="https://www.protondb.com/app/'+appId+'"';
			if(sorted.length>0){
				if(sum != rating[1] && has_filter)
					content += ' title="'+sum+' of '+rating[1]+' overall rating(s) apply">';
				else
					content += ' title="'+rating[1]+' overall ratings">';
				content += sorted.join(' / ');
			} else if(has_filter && rating[1]>0) {
				content += ">None of the "+rating[1]+" ratings apply";
			} else content += ">No ratings yet";
			content += '</a></td>';
			node.setAttribute('id', 'spcrn');
			node.innerHTML = content;
			node.firstElementChild.appendChild(createButton());
			target.appendChild(node);
		}
	}
	injectors['pcgamingwiki.com']= function(appId, rating){
		let row1 = document.createElement('tr'), row2 = document.createElement('tr'),
		    target = document.querySelector('table#infobox-game tbody'), content;
		if(target){
			row1.innerHTML = '<th colspan="2" class="template-infobox-header">Steam Play Community Rating</th>';
			content = document.createElement('td');
			content.setAttribute('colspan',2);
			content.setAttribute('class','template-infobox-info');
			content.setAttribute('style','padding:4px 0');
			content.appendChild(createBadge(rating,appId));
			row2.appendChild(content);
			target.appendChild(row1);
			target.appendChild(row2);
			row1.firstElementChild.appendChild(createButton());
		}
	}
	injectors['www.fanatical.com']= function(appId, rating){
		let target = document.querySelector('.product h1'), badge;
		if(target) {
			badge = createBadge(rating,appId);
			badge.style.marginLeft = '20px';
			badge.style.fontWeight = '450';
			badge.style.verticalAlign = 'middle';
			button = createButton();
			button.style.verticalAlign= 'middle';
			target.appendChild(badge);
			target.appendChild(button);
		}
	}
	injectors['isthereanydeal.com']= function(appId, rating){
		let target = document.querySelector('#gameTitle, #popupContent .cntBoxTitleTriangle'), badge;
		if(target) {
			badge = createBadge(rating,appId);
			badge.style.marginLeft = '20px';
			badge.style.fontWeight = '450';
			badge.style.verticalAlign = 'middle';
			button = createButton();
			button.style.verticalAlign= 'middle';
			target.appendChild(badge);
			target.appendChild(button);
			if(!target.id && target.nodeName==='DIV'){
				target.style.float='right';
				target.removeAttribute('class');
			}
		}
	}
	injectors['lutris.net']= function(appId, rating){
		let target = document.querySelector('.game-info ul'), content;
		if(target) {
			content = document.createElement('li');
			content.style.textAlign = 'center';
			content.appendChild(createBadge(rating,appId));
			content.appendChild(createButton());
			target.appendChild(content);
		}
	}

	function reqListener (response) {
		let data=[];
		response = (this.hasOwnProperty('responseText'))? this.responseText : response.responseText;
		if(response.charAt(0) !== '<'){
			data = JSON.parse(response);
			if(typeof data === 'object'){
				cache[matches[1]]={'ratings': data, 'fetched': Date.parse(new Date().toUTCString())};
				xdValue('spcrnCache',JSON.stringify(cache));
			}
		}
		memorize(data);
		inject(matches[1], memo[matches[1]]);
	}

	function Ratings(data){
		let rawData = data || [], scores = null, regexp, tstamp,
		filter = function(item){
			// console.log(config);
			if('filters' in config)
				for(let fieldName in config.filters){
					if(item.hasOwnProperty(fieldName) && config.filters[fieldName]!=''){
						switch(fieldName){
							case 'timestamp':
								tstamp = config.filters[fieldName];
								if(tstamp > 0 && item[fieldName] > tstamp)
									return false;
								break;
							default:
								regexp = new RegExp(config.filters[fieldName],'i');
								if(item[fieldName]!='' && item[fieldName].match(regexp)===null)
									return false;
						}
					}
				}
			return true;
		};
		this.mostFrequent = function(){
			if(scores === null) scores = this.groupedScores();
			return Object.keys(scores).reduce((a, b) => scores[a] > scores[b] ? a : b, 'N/A');
		}
		this.groupedScores = function(){
			if(scores === null){
				let idx;
				scores={};
				rawData.forEach(function(item){
					idx = item.rating;
					if(filter(item)){
						scores[idx]=(scores[idx] || 0);
						scores[idx]++;
					}
				});
			}
			return Object.keys(scores).sort((a, b) => scores[b]-scores[a]).map(key => ({[key]:scores[key]}) );
		}
	}

	function investigate(cb) {
		let regex=/\/(\d+)\/?/, tries=5, intv;
		switch (document.location.hostname) {
			case 'store.steampowered.com':
				if(document.querySelector('.platform_img.linux, .sysreq_tab[data-os=linux]') === null)
					matches = document.location.pathname.match(regex);
				break;
			case 'steamdb.info':
				if(document.querySelector('.scope-app .icon-linux') === null)
					matches = document.location.pathname.match(regex);
				break;
			case 'lutris.net':
				let gameInfo = document.querySelector('.game-info');
				if(gameInfo && gameInfo.textContent.indexOf('Linux') === -1)
					matches = document.querySelector(
						'.game-info a.external-link[href*="://store.steampowered.com/app/"],' +
						'.game-info a.external-link[href*="://steamdb.info/app/"]'
					)
				if(matches) matches=matches.href.match(regex);
				break;
			case 'pcgamingwiki.com':
				if(document.querySelector('#table-availability .os-linux') === null)
					matches = document.querySelector('#table-availability a[href^="https://store.steampowered.com/app/"]').href.match(regex);
				break;
			case 'isthereanydeal.com':
				var wait = 1000, check = function(){
					let link, platforms = document.querySelector('.priceTable__platforms');
					if(platforms && platforms.textContent.indexOf('Linux') === -1){
						link = document.querySelector('.priceTable a[href^="https://store.steampowered.com/app/"]');
						if(link){
							matches = link.href.match(regex);
							if(matches){
								clearInterval(intv);
								cb();
							}
						}
					};
					if(tries-- === 0) clearInterval(intv);
				};
				intv = setInterval(check, wait);
				document.addEventListener('click', function(ev){
					if(ev.target.hasAttribute('href') && ev.target.getAttribute('href').indexOf('#/page:game/info?plain') !== -1){
						tries=5;
						clearInterval(intv);
						intv = setInterval(check, wait);
					}
				}, false);
				break;
			case 'www.fanatical.com':
				var check = function(){
					if(document.querySelector('.system-requirements .fa-linux') === null){
						let link = document.querySelector('.product-details a[href^="http://store.steampowered.com/app/"]');
						if(link){
							matches = link.href.match(regex);
							if(matches){
								clearInterval(intv);
								cb();
							}
						}
					};
					if(tries-- === 0 || document.location.pathname.indexOf('/game/') === -1)
						clearInterval(intv);
				};
				intv = setInterval(check, 1500);
				document.addEventListener('click', function(ev){
					if(ev.target.hasAttribute('href') && ev.target.getAttribute('href').indexOf('/game/') !== -1){
						tries=5;
						clearInterval(intv);
						intv = setInterval(check, 1500);
					}
				}, false);
			break;
		}
		cb();
	}

	function memorize(rawData){
		let rating='N/A', all=[], ratings;
		if(rawData.length>0){
			ratings = new Ratings(rawData);
			all = ratings.groupedScores();
			rating = ratings.mostFrequent();
		}
		memo[matches[1]]=[rating, rawData.length, all];
		sessionStorage.setItem('spcrn', JSON.stringify(memo));
	}

	function setupConfig(){
		let filterNames = Object.keys(cfgFields), value, filters=[];
		filterNames.forEach(function(fname){
			value = GM_config.get(fname);
			if(value !== '') filters.push({[fname]:value});
		});
		if(filters.length>0) config.filters=Object.assign(...filters);
	}

	function execute(){
		if(matches && matches[1]){
			xdValue('spcrnCache').then(function(data){
				let is_stale = true, xhr, params, func, item;
				cache = JSON.parse(data || '{}');
				// console.log(cache);
				if(matches[1] in cache){
					item=cache[matches[1]];
					is_stale = Date.parse(new Date().toUTCString()) - item.fetched > MS2DAY; // == 1 day
				}
				setupConfig();
				if(is_stale === false ){
					if(!(matches[1] in memo)) memorize(item.ratings);
					inject(matches[1], memo[matches[1]]);
				} else {
					//console.log('xhr');
					params = {
						method: "GET",
						url: "https://protondb.max-p.me/games/"+matches[1]+"/reports",
						headers: {
							"Accept": "application/json",
							"User-Agent": navigator.userAgent + " UserJS/SPCRN"
						},
						onload: reqListener
					};
					func = ajaj();
					if(typeof func === 'function') func(params);
					else {
						xhr = new XMLHttpRequest();
						xhr.addEventListener("load", reqListener);
						xhr.open("GET", params.url);
						xhr.setRequestHeader('Accept', params.headers['Accept']);
						xhr.setRequestHeader('User-Agent', params.headers['User-Agent']);
						xhr.send();
					}
				}
			});
		}
	}

	investigate(execute);

	// compatibility stuff
	function ajaj(){
		if(typeof GM !== 'undefined')
			return GM.xmlHttpRequest;
		else if(typeof GM_xmlhttpRequest === 'function')
			return GM_xmlhttpRequest;
	}
	function xdValue(name, value){
		let get,set;
		if(typeof GM !== 'undefined'){
			get = GM.getValue(name);
			set = GM.setValue;
		} else {
			get = {then:(cb)=> cb(GM_getValue(name))};
			set = GM_setValue;
		}
		if(value !== undefined)
			set(name,value);
		return get;
	}
}());