<|start_filename|>index.js<|end_filename|>
/*
 * Due to memory issues with puppeteer, and because all this async stuff makes
 * garbage collection hard, this process runs ONCE for one job in the queue.
 * After the jobs in Chrome and Chrome with uBlock Origin are finished, the process is quit
 * Xvfb is killed, so is Chrome, and thus also puppeteer.
 * Another process should restart this, so the next item in the queue is picked
 */

const puppeteer = require('puppeteer-core');
const redis = require('redis').createClient();
const { spawn } = require('child_process');

const ENV = process.argv[2] || 'dev';

var displays, puppeteerPorts;

if( ENV == 'prod' ){
	displays = [0,1];
	puppeteerPorts = [9992,9993];
} else if( ENV == 'dev' ){
	displays = [2,3];
	puppeteerPorts = [9994,9995];
} else {
	console.log('Unknown ENV, exiting..');
	process.exit(1);
}

var redisThrottle = null;
function log(url, browser, data, data2){
	if( data2 == undefined ){
		data2 = '';
	}
	if( results[url] == undefined ){
		results[url] = {};
	}
	if( results[url][browser] == undefined ){
		results[url][browser] = {};
	}

	results[url][browser][data] = data2;
	console.log((new Date()).toTimeString(), browser, url, data, data2);

	clearTimeout(redisThrottle);
	redisThrottle = setTimeout(() => {
		redis.set('A-' + url, JSON.stringify(results[url]));
	}, 50);
}

function delay(ms) {
	return new Promise(r => setTimeout(r, ms));
}

var browsers = {};
var results = {};

function navigate(url){
	console.log('navigate', url);
	results[url] = {};
	for( b in browsers ) {
		results[url][b] = {init: true};
		connect(b, browsers[b], url);

		setTimeout(() => {
			// always end after one minute
			forceShutdown(url);
		}, 60000);
	}
}

function humanize(value, type){
	if( value < 0 ){
		return {value: value, human: '0'};
	}
	if( type == undefined ){
		return {value: value, human: value};
	}
	if( type == 's' ){
		return {value: value, human: (value / 1000).toFixed(2) + ' s'};
	}
	if( type == 'mb' ){
		return {value: value, human: (value / 1024 / 1024).toFixed(2) + ' MB'};
	}
	return {value: value, human: value};
}

function cleanup(){
	spawn('killall', ['chrome']);
	spawn('killall', ['google-chrome']);
	spawn('killall', ['Xvfb']);
}

var childProcesses = [];
function initXvfb(){
	// spawn two Xvfb's
	childProcesses.push(spawn('Xvfb', [':' + displays[0]]));
	childProcesses.push(spawn('Xvfb', [':' + displays[1]]));
}

function spawnBrowser(browserName, path, params, shellEnv, callback){
	var prog = spawn(path, params, {env: shellEnv});

	childProcesses.push(prog);

	prog.stdout.on('data', (data) => {
		//log(browserName, data.toString());
	});

	prog.stderr.on('data', (data) => {
		//console.log(data.toString());
		if( data.toString().indexOf('DevTools listening on') == -1 ){
			return;
		}
		var wsEndpoint = data.toString().replace('DevTools listening on', '').trim();
		console.log(browserName, wsEndpoint);
		browsers[browserName] = wsEndpoint;
		callback(browserName);	
	});

	console.log(browserName, 'Launched');
}

function popFromQueue(){
	redis.brpop('queue', 1, (error, job) => {
		//console.log('Check job', error, job);
		if( error || !job ){
			delay(500).then(popFromQueue);
			return;
		}
		console.log(JSON.parse(job[1]));
		navigate(JSON.parse(job[1]).url);
	});
}

process.on('SIGTERM', () => {
	console.log('Exiting');
	for( c in childProcesses ){
		childProcesses[c].kill();
	}
});


cleanup();
initXvfb();

var spawnParams = [
	'--remote-debugging-port=PORT',
	'--user-data-dir=tmp-dir-0',
	'--no-first-run',
	'--incognito',
	//'--media-cache-size=1',
	//'--disk-cache-size=1',
	'--disable-gpu'
];

var slowStartTimeout = setTimeout(() => {
	if( spawnCount < 2 ){
		console.log('Too slow. exit..');
		cleanup();
		process.exit(1);
	}
}, 10000);

var spawnCount = 0;
function isSpawned(){
	spawnCount++;
	if( spawnCount >= 2 ){
		clearTimeout(slowStartTimeout);
		popFromQueue();
	}
}
function forceShutdown(url){
	for( b in browsers ){
		log(url,b, 'Done', 'Done');
	}
	setTimeout(() => {
		cleanup();
		process.exit(0);
	}, 1000);

}
function isEnded(url){
	spawnCount--;
	if( spawnCount <= 0 ){
		forceShutdown(url);
	}
}

spawnParams[0] = spawnParams[0].replace('PORT', puppeteerPorts[0]);
spawnBrowser('Normal', 'google-chrome', spawnParams, {DISPLAY: ':' + displays[0]}, isSpawned);

spawnParams[0] = spawnParams[0].replace(puppeteerPorts[0], puppeteerPorts[1]);
spawnParams[1] = spawnParams[1].replace('-0', '-1');
spawnParams.push('--load-extension=../../chrome-extensions/ublock-origin')

spawnBrowser('uBlock', 'google-chrome', spawnParams, {DISPLAY: ':' + displays[1]}, isSpawned);

function connect(browserName, wsEndpoint, url){
	puppeteer.connect({
		browserWSEndpoint: wsEndpoint
	}).then(async browser => {

		const page = await browser.newPage();

		page.setViewport({width: 1680, height: 952});
		page.setCacheEnabled(false);

		await page._client.send('Network.clearBrowserCookies');
		await page._client.send('Network.clearBrowserCache');

		//page.once('error', (title, error) => log(url, browserName, 'Error', error));

		/*await page.tracing.start({
			path: browserName + '-trace.json', 
			//categories: ['devtools.timeline']
		});*/

		var advanced = {};

		var requestsBlocked = 0;
		page.on('requestfailed', (err) => {
			if( err._failureText == 'net::ERR_BLOCKED_BY_CLIENT' ){
				requestsBlocked++;
				log(url, browserName, 'Blocked requests', humanize(requestsBlocked));
			}
			//log(url, browserName, err._failureText + ' ' + err._url);
		});

		var cookieInterval = setInterval(async () => {
			//var cookies = await page.cookies();
			//log(url, browserName, 'Cookies', humanize(cookies.length));
			var metrics = await page._client.send('Performance.getMetrics');
			for( m in metrics ){
				if( metrics[m].name == 'Frames' ){
					log(url, browserName, 'iFrames', humanize(metrics[m].value));
				}
			}

			var allCookies = await page._client.send('Network.getAllCookies');

			//log(url, browserName, 'dCookies', allCookies.cookies);

			if( allCookies && allCookies.cookies ){
				var cookieDomains = {};
				for( c in allCookies.cookies ){
					var domain = allCookies.cookies[c].domain.replace(/^\./, '');
					if( cookieDomains[domain] == undefined ){
						cookieDomains[domain] = 0;
					}
					cookieDomains[domain]++;
				}

				log(url, browserName, 'Cookies', humanize(allCookies.cookies.length));
				
				log(url, browserName, 'CookieDomains', cookieDomains);
			}

		}, 1000);	
	
		function hasEnded(){
			log(url, browserName, 'Done', 'Done');
			clearTimeout(noResponsesReceivedTimeout);
			clearInterval(cookieInterval);
			page.close();
			isEnded(url);
		}

		page.once('close', () => {
			browser.disconnect();
		});

		// end this job if no request came in in the last 4 seconds
		var requestCounter = 0;
		var totalSize = 0;
		var noResponsesReceivedTimeout = null;
		var isLoaded = false;
		page.on('response', (res) => {
			//console.log(res);
			if( res.status() >= 200 && res.status() <= 399 ){
				/*res.buffer().then(buffer => {
					totalSize += buffer.length;
				});*/
				/*if( res._headers['content-length'] !== undefined ){
					totalSize += parseInt(res._headers['content-length'], 10);
				}*/
				requestCounter++;
				log(url, browserName, 'Requests', humanize(requestCounter));
				//log(url, browserName, 'Page size', humanize(totalSize, 'mb'));
			}
			if( isLoaded ){
				clearTimeout(noResponsesReceivedTimeout);
				noResponsesReceivedTimeout = setTimeout(hasEnded, 2000);
			}
		});

		var totalBytes = 0;
		page._client.on('Network.dataReceived', (event) => {
			totalBytes += event.encodedDataLength;
			log(url, browserName, 'Page size', humanize(totalBytes, 'mb'));
		});

		page.on('pagerror', (err) => {
			log(url, browserName, 'Past error', err);
		});

		log(url, browserName, 'Navigate', url);
		var timeBefore = Date.now();
		page.once('load', () => {
			log(url, browserName, 'Page loaded!');

			var timeAfter = Date.now();
			log(url, browserName, 'Load time',  humanize(timeAfter - timeBefore, 's'));	
	
			// Stop everything after 20 seconds
			setTimeout(hasEnded, 20000);
			isLoaded = true;
			
			// make a screenshot after 1 second
			delay(1000).then(() => {
				var screenshotPath = '/screenshots/' + Date.now() + '-' + (Math.random() + '').substring(2) + '-' + browserName + '.png';
				page.screenshot({path: 'public' + screenshotPath}).then(() => {
					log(url, browserName, 'Screenshot', screenshotPath);
				});
			});
		});

		try {
			await page.goto(url);
		} catch(err){
			//console.error(err);
		}
		
		const timing = JSON.parse(
			await page.evaluate(() => JSON.stringify(window.performance.timing))
		);
		log(url, browserName, 'Timings', timing);
	
		let performanceMetrics = await page._client.send('Performance.getMetrics');
		log(url, browserName, 'AllMetrics', performanceMetrics.metrics);

		var metrics = {};

		for( m in performanceMetrics.metrics ){
			metrics[performanceMetrics.metrics[m].name] = performanceMetrics.metrics[m].value;
		}
				
		log(url, browserName, 'Metrics', metrics);
		log(url, browserName, 'ProcessingTime', humanize(timing.loadEventEnd - timing.domLoading, 's'));
		log(url, browserName, 'DomContentLoaded', humanize(timing.domContentLoadedEventStart - timing.navigationStart, 's'));
		log(url, browserName, 'Load time', humanize(timing.loadEventStart - timing.navigationStart, 's'));	
		
		log(url, browserName, 'iFrames', humanize(metrics.Frames));
			
		// auto consent accept:
					
		setTimeout(function(){
			try {
				// quantcast
				var frames = page.frames();
				var consentFrame = frames.find(f => f.name() === 'cmp-ui-iframe');
				consentFrame.$eval('#mainAgree', el => el.click() );

			} catch( err ){
			}
			try {
				// yahoo & partners
				page.$eval('button[name=agree]', el => el.click() );
			} catch( err ){
			}
			try {
				page.$eval('button.consent-accept-all', el => el.click() );
			} catch( err ){
			}
		}, 10);

		//const pageMetrics = await page.metrics();

		//log(url, browserName, 'page.metrics()', pageMetrics);

		//await browser.close();
	});
}


<|start_filename|>server.js<|end_filename|>
const express = require('express');
const redis = require('redis').createClient();
const mysql = require('mysql').createConnection({
	host: 'localhost',
	user: 'webtest',
	password: <PASSWORD>', // No worries :-)
	database: 'webtest'
});

const app = express();

redis.on('connect', () => {
	console.log('Redis connected');
	redis.del('queue');	
});
redis.on('error', (err) => console.log('Redis error: ' + err));

app.disable('etag');


function addToQueue(job){
	redis.lpush.apply(redis, ['queue', JSON.stringify(job)]);
}

function log(data){
	console.log((new Date()).toTimeString(), data);
}

function urlIsValid(url){
	if( !url.startsWith('http') ){
		return false;
	}
	var notAllowedUrlParts = ['webtest.app', 'file://', 'localhost', '127.0.0.1', '::1'];
	for( n in notAllowedUrlParts ){
		if( url.indexOf(notAllowedUrlParts[n]) >= 0 && url.indexOf(notAllowedUrlParts[n]) <= 10 ){
			return false;
		}
	}
	return true;
}

// we want POST, but you need more deps with bodyparse stuff which sucks
app.get('/service/add', (req, res) => {
	var url = req.query.parm.trim();
	if( !urlIsValid(url) ){
		res.send({status: 'error'});
		return;
	}
	redis.get('A-' + url, (error, result) => {
		if( error || !result ){
			log('Add ' + url);
			addToQueue({
				time: Date.now(),
				url: url,
				status: 'queued'
			});
		}
	});
	res.send({status: 'queued'});
});
app.get('/service/status', (req, res) => {
	//console.log('A-' + req.query.parm);
	var url = req.query.parm.trim();
	if( !urlIsValid(url) ){
		res.send({status: 'error'});
		return;
	}
	redis.get('A-' + url, (error, result) => {
		//console.log(error, result);
		if( error || !result ){
			redis.lrange('queue', 0, -1, (error, items) => {
				if( error || !items || items.length == 0 ){
					res.send({status: 'queued'});
					return;
				}
				for( i in items ){
					if( JSON.parse(items[i]).url == url ){
						res.send(items[i]);
						return;
					}
				}
			});
			return;
		}
		res.send(result);	
	});
});

app.get('/service/list', (req, res) => {
	mysql.query("SELECT urls.url, tv.variant, tv.requests, tv.cookies FROM urls INNER JOIN tests ON tests.url_id = urls.id INNER JOIN tests_variants tv ON tv.test_id = tests.id WHERE urls.url LIKE 'https://%' ORDER BY urls.id ASC LIMIT " + Math.round(Math.random() * 2000) + ",100", (error, result) => {
		var urls = {};
		for( r in result ){
			var row = result[r];

			if( row.url.indexOf('x') >= 1 || row.url.indexOf('127') >= 1 || row.url.indexOf('porn') >= 1 ){
				continue;
			}

			if( urls[row.url] == undefined ){
				urls[row.url] = {};
			}
			urls[row.url][row.variant] = {requests: row.requests, cookies: row.cookies};
		}
		res.send(urls);
	});	
});

const ENV = process.argv[2] || 'dev';

var port = null;

if( ENV == 'prod' ){
	port = 3000;
} else if( ENV == 'dev' ){
	port = 3001;
} else {
	console.log('Unknown ENV, exiting..');
	process.exit(1);
}

app.listen(port, 'localhost', () => console.log('Express running on port', port));


<|start_filename|>public/index.html<|end_filename|>
<!DOCTYPE html>
<html lang="en">
<head prefix="og: http://ogp.me/ns/website#">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0" />
<title>Webtest.app - Testing websites with and without adblocker</title>
<meta name="robots" content="index,follow" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<meta property="og:type" content="website">
<meta property="og:title" content="Webtest.app - Testing websites with and without adblocker">
<meta property="og:description" content="A website speed test tool to compare uBlock Origin with plain Chrome. Check the weight of your ad implementation. Please consider the environment before loading a bunch of ads on your website.">
<meta property="og:image" content="https://webtest.app/website-speed-test.png">
<meta property="og:url" content="https://webtest.app">

<link rel="stylesheet" href="/webtest.css">

<meta name="description" content="A website speed test tool to compare uBlock Origin with plain Chrome. Check the weight of your ad implementation. Please consider the environment before loading a bunch of ads on your website." />
</head>
<body>
<div id="header">
	<header class="content">
		<h1><a href="/">Website Speed Test</a><br/><span class="subtitle">With &amp; Without Ad Blocker</span></h1>
		<h2>Find out how much overhead your ad implementation adds to your website</h2>
	</header>
	<form id="urlForm" class="content">
		<input id="url" type="url" name="url" value="https://" required />
		<input type="submit" value="Test" />
	</form>
</div>

<div class="content">

	<div id="resultsWrapper">
		<div id="status"></div>
		<div class="loader"></div>
		<div class="column">
			<h4>Chrome</h4>
			<div id="screenshotNormal" class="screenshot"></div>
			<div id="resultsNormal" class="results"></div>
		</div>
		<div class="column">
			<h4>uBlock Origin</h4>
			<div id="screenshotuBlock" class="screenshot"></div>
			<div id="resultsuBlock" class="results"></div>
		</div>

		<div style="clear:both;"></div>

		<div class="column">
			<h3>Cookies per domain</h3>
			<table class="advanced"><tbody id="cookiesNormal"></tbody></table>
		</div>
		<div class="column">
			<h3>Cookies per domain</h3>
			<table class="advanced"><tbody id="cookiesuBlock"></tbody></table>
		</div>
		<div style="clear:both;"></div>
		<div class="column">
			<h3>Advanced metrics</h3>
			<table class="advanced"><tbody id="advancedNormal"></tbody></table>
		</div>
		<div class="column">
			<h3>Advanced metrics</h3>
			<table class="advanced"><tbody id="advanceduBlock"></tbody></table>
		</div>
	</div>

	<div style="clear:both;"></div>

	<h3>How does this work?</h3>
	<p>This service visits the URL you enter and gives you various statistics about the website. There are many speedtest tools out there, but this speedtest has one unique feature: For one visit it's just running Chrome normally. For the other we still use Chrome, but then with the uBlock Origin extension loaded.</p>	
	<p>Are you looking for some examples? Check a random selection of 
		<a href="/list.html">URLs that have been tested.</a>
	</p>
	<p>Don't just test the homepage of a website. Definitely try sub pages like articles etc.</p>

	<h3>Warning: pre-alpha state</h3>
	<p>This service is in a pre-alpha state and might crash, or the queue can fill up when everybody is testing. The queue is sometimes cleaned up. You can always give it a try :-).</p>

	<h3>It looks like websites download a lot of stuff I don't need!</h3>
	<p>Yes, that's what you get for not using an ad blocker.</p>

	<h3 id="energy">How is energy consumption measured?</h3>
	<p>For now this test only displays the estimated Watt Hour for transferring the bytes of the web page. 
	<a href="https://onlinelibrary.wiley.com/doi/full/10.1111/jiec.12630" target="_blank">Source</a>. 
	Data transfer is not all it takes to run ads, there is also data crunching happening on servers and rendering of the ads on clients.</p>

	<h3>What technology is used?</h3>
	<p>This service uses nginx, node, puppeteer, redis, chrome on a xvfb display and of course ublock origin.
	For now only the default filters are enabled for uBlock Origin. Xvfb is needed because headless chrome cannot load plugins. 
	Firefox support might be supported in the future. Git repos coming up soon.</p>

	<h3>Is this an alternative to pingdom or dotcom tools?</h3>
	<p>No, please use their services to test your website speed. Use this service to test your ad implementation, 
	or as proof to others they should use an ad blocker.</p>

	<h3>This service needs a slogan, a better title and more exciting explanation.</h3>
	<p>If you want to help, please <a href="https://news.ycombinator.com/item?id=21052969" target"_blank">join on HN</a>.</p>
	<p>What about: please consider the environment before loading a bunch of ads on your website.</p>
	<p>Check the weight of your ad implementation.</p>

	<h3>Contact</h3>
	<p>aberforthqueensalad 🍊 protonmail 🍋 com</p>

</div>

<script>
var fields = ['Requests', 'Blocked requests', 'Cookies', 'Page size', 'iFrames', 'DomContentLoaded', 'ProcessingTime', 'Load time'],
	resultColumns = {
		Normal: document.querySelector('#resultsNormal'), 
		uBlock: document.querySelector('#resultsuBlock')
	},
	screenshotElements = {
		Normal: document.querySelector('#screenshotNormal'), 
		uBlock: document.querySelector('#screenshotuBlock')
	},
	advancedElements = {
		Normal: document.querySelector('#advancedNormal'),
		uBlock: document.querySelector('#advanceduBlock')
	},
	cookieElements = {
		Normal: document.querySelector('#cookiesNormal'),
		uBlock: document.querySelector('#cookiesuBlock')
	},
	urlForm = document.querySelector('#urlForm');

function cleanup(){

}

function submitForm(callback){
	var url = document.querySelector('#url').value;

	document.querySelector('#resultsWrapper').style.visibility = 'visible';
	document.querySelector('.loader').style.display = 'block';

	for( r in resultColumns ){
		resultColumns[r].innerHTML = '';
		screenshotElements[r].innerHTML = '';
		advancedElements[r].innerHTML = '';
		cookieElements[r].innerHTML = '';
	}

	urlForm.style.display = 'none';

	function poll(){
		fetch('/service/status?parm=' + encodeURIComponent(url)).then(function(response){
			return response.json();
		}).then(function(json){	

			if( json.status ){
				document.querySelector('#status').innerText = 'status: ' + json.status;	
				if( json.status == 'queued' ){
					setTimeout(poll, 1000);
					return;
				} else if( json.status == 'error' ){

					document.querySelector('.loader').style.display = 'none';
					urlForm.style.display = 'block';
					return;
				}
			} else {
				document.querySelector('#status').innerText = '';
			}

			for( variant in json ){
				resultColumns[variant].innerHTML = '';

				if( json[variant].Screenshot != undefined && screenshotElements[variant].innerHTML == '' ){
					screenshotElements[variant].innerHTML = '<a href="' + json[variant].Screenshot + '" target="_blank"><img src="' + json[variant].Screenshot + '" width="100%" /></a>';
					document.body.className = 'has-screenshot';
				}

				for( f in fields ){
					resultColumns[variant].innerHTML += '<div>:title:<br/><b>:val:</b></div>'
						.replace(':val:', json[variant][fields[f]] ? json[variant][fields[f]].human : '-')
						.replace(':title:', fields[f]);

					if( fields[f] == 'Page size' && json[variant][fields[f]] ){
						var amazingMathematics = json[variant][fields[f]].value / 1024 / 1024 / 1024 * 0.06 * 1000;
						resultColumns[variant].innerHTML += '<div class="energy">Energy used<br/>' + 
							'<b>:val: Wh</b> <br/><a href="#energy">more info</a></div>'
							.replace(':val:', amazingMathematics.toFixed(3));
					}
				}	

				advancedElements[variant].innerHTML = '';
				for( m in json[variant].Metrics ){
					advancedElements[variant].innerHTML += '<tr><td>' + m + '</td><td>' 
						+ json[variant].Metrics[m] + '</td></tr>';
				}

				cookieElements[variant].innerHTML = '';
				for( m in json[variant].CookieDomains ){
					cookieElements[variant].innerHTML += '<tr><td>' + m + '</td><td>' 
						+ json[variant].CookieDomains[m] + '</td></tr>';
				}
			}

			if( json.Normal == undefined || json.uBlock == undefined || json.Normal['Done'] == undefined || json.uBlock['Done'] == undefined ){
				setTimeout(poll, 1000);
			} else {
				document.querySelector('.loader').style.display = 'none';
				urlForm.style.display = 'block';
			}
		});
	}
	fetch('/service/add?parm=' + encodeURIComponent(url)).then(function(){
		poll();
		if( callback ){
			callback();
		}
	});
}

function checkParams(){
	if( location.search.indexOf('?url=') >= 0 ){
		var url = decodeURIComponent(location.search.replace(/^\?url=/, ''));
		if( url.indexOf('?') == -1 ){
			url = url.replace(/&.*$/, '');
		}
		document.querySelector('#url').value = url;
		submitForm();
	}
}
checkParams();

var first = true;
urlForm.addEventListener('submit', function(e){
	e.preventDefault();
	submitForm(function(){
		var url = document.querySelector('#url').value;
		if( first ){
			history.replaceState({}, document.title, '?url=' + url);
		} else {
			history.pushState({}, document.title, '?url=' + url);
		}
		first = false;
	});
});

window.onpopstate = function(){
	if( location.search.length > 2 ){
		checkParams();
	} else {
		cleanup();
	}
};

</script>

</body>
</html>


<|start_filename|>public/webtest.css<|end_filename|>
html, body {
	margin: 0;
	padding: 0;
}
body {
	font: 16px "Lucida Sans Unicode", "Lucida Grande", sans-serif;
	color: #333;
	-webkit-font-smoothing: antialiased;
	-moz-osx-font-smoothing: grayscale;
	text-rendering: optimizeLegibility;
	padding-bottom: 40px;
}
#header {
	padding-top: 7%;
	background: #e7ffe7;
	border-bottom: 1px solid #90EE90;
}
header {
	text-align: center;
}
p {
	line-height: 1.5;
}
h1 {
	line-height: 22px;
	font-weight: normal;
	margin: 0 0 100px 0;
}
h1 a {
	color: inherit;
	text-decoration: none;
}
h1 a:hover {
	text-decoration: underline;
}
h2,
.subtitle {
	font-size: 16px;
	font-weight: normal;
}
h3 {
	padding-top: 40px;
	margin-top: 0;
}
.content {
	max-width: 600px;
	margin: 20px auto;
	padding: 0 20px;
}
#resultsWrapper {
	visibility: hidden;
	margin: 20px 0;
}
#status {
	text-align: center;
}
table {
	border-collapse: collapse;
	border: none;
}
table.advanced {
	border: 1px solid #333;
	margin: 0 auto;
	background: #f6f6f6;
	max-width: 90%;
}
.advanced th, .advanced td {
	border: 1px solid #eee;
	line-height: 1.8;
	padding: 0 4px;
	text-align: left;
	font-size: 12px;
}
@media (min-width: 500px) {
	th,td {
		font-size: 14px;
	}
}
td:first-child {
	word-break: break-all;
}
td:last-child {
	text-align: right;
}
form.content {
	text-align: center;
	margin-bottom: -20px;
}
input {
	box-sizing: border-box;
	line-height: 40px;
	font-size: inherit;
	padding: 0 10px;
	border: none;
}
input[type=url] {
	width: calc(100% - 90px);
}
input[type=submit] {
	width: 80px;
	cursor: pointer;
}
input {
	border: 1px solid #769a76;
	border-radius: 3px;
}
input:hover {
	outline: 1px solid #90EE90;
}
.column {
	width: 50%;
	float: left;
	box-sizing: border-box;
	margin: 10px 0;
	border-radius: 3px;
	text-align: center;
}
.column h3 {
	font-size: 16px;
	font-weight: normal;
}
#list {
	width: 100%;
}
#list td, #list th {
	text-align: right;
	padding: 5px;
}
#list th {
	padding-bottom: 20px;
}
#list td.url, #list th.url {
	text-align: left;
}
#list a {
	color: inherit;
}
#list .value {
	font-weight: bold;
	font-size: 20px;
}
.separator {
	height: 15px;
}
.results > div {
	border: 1px solid #eee;
	padding: 6%;
	line-height: 30px;
	box-shadow: 0 1px 2px 0 rgba(0,0,0,.05);
	border-radius: 3px;
	width: 50%;
	margin: 20px auto;
	text-align: center;
}
.results > div.energy {
	padding-bottom: 2%;
}
.results b {
	font-size: 26px;
}
.results a {
	color: inherit;
	font-size: 12px;
}
.loader,
.loader:after {
	border-radius: 50%;
	width: 10em;
	height: 10em;
}
.loader {
	display: none;
	margin: 20px auto;
	font-size: 10px;
	position: relative;
	text-indent: -9999em;
	border-top: 1.1em solid rgba(144, 238, 144, 0.2);
	border-right: 1.1em solid rgba(144, 238, 144, 0.2);
	border-bottom: 1.1em solid rgba(144, 238, 144, 0.2);
	border-left: 1.1em solid #90EE90;
	-webkit-transform: translateZ(0);
	-ms-transform: translateZ(0);
	transform: translateZ(0);
	-webkit-animation: load8 1.1s infinite linear;
	animation: load8 1.1s infinite linear;
}
.screenshot a {
	display: block;
}
.has-screenshot .screenshot {
	margin: 0 auto;
	max-width: 63%;
	background: #f4f4f4;
}
@-webkit-keyframes load8 {
	0% {
		-webkit-transform: rotate(0deg);
		transform: rotate(0deg);
	}
	100% {
		-webkit-transform: rotate(360deg);
		transform: rotate(360deg);
	}
}
@keyframes load8 {
	0% {
		-webkit-transform: rotate(0deg);
		transform: rotate(0deg);
	}
	100% {
		-webkit-transform: rotate(360deg);
		transform: rotate(360deg);
	}
}

