Retrieve Full Page Titles in Google Search

Fill the page link titles with the full respective page titles, if possible

// ==UserScript==
// @name		Retrieve Full Page Titles in Google Search
// @description	Fill the page link titles with the full respective page titles, if possible
// @version		2.0.1
// @namespace	Google
// @author		Benjamin Philipp <dev [at - please don't spam] benjamin-philipp.com>
// @include		/https?:\/\/(www\.)?google\.[a-z\.]{2,6}\/(search|webhp)\?((?!tbm=isch).)*$/
// @require 	http://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js
// @require		https://openuserjs.org/src/libs/sizzle/GM_config.js
// @require 	https://greasyfork.org/scripts/447081-bp-funcs/code/BP%20Funcs.js
// @run-at		document-body
// @noframes
// @grant		GM_addStyle
// @grant		GM_xmlhttpRequest
// @grant		GM_registerMenuCommand
// @grant		GM_getValue
// @grant		GM_setValue
// @connect		*
// ==/UserScript==

// 2.0.0
// - Using GM_Config for settings now (https://github.com/sizzlemctwizzle/GM_config) 🖤
//   (Available from the Tampermonkey menu, under the script name)
// - Added setting to disregard new titles if they're shorter than the old ones. Duh, should have done that from the start ^^
//   (ON by default)
// - The new titles, as well as additional info (old titles, errors, warnings) are now always shown in the element title (mouse hover tooltip), where applicable
// - Moved away from extending the whole layout width in favor of just showing the overflowing new titles.
//   This is to prevent clashes with google widgets and panels that may appear on the right.
//   If anything is covered, the longet titles are now always available on mouse hover over the links.
// - Fixed a potential issue where titles containing special chars could inadvertently render HTML instead of just their escaped text 😬
// - Using my function library again, also for nicer logging
// - moved to classes for styling instead of "spaghetti" element styles
// I dunno, probably some other stuff. I'm an artist, not a book keeper 😜
// 1.9.7:
// added newer CSS variable "center-column-width" for unrestricting column width
// 1.9.6:
// Remove width limit for results column and items
// 1.9.5:
// corrected selectors for color changes
// 1.9.4:
// changed background colors from "very light shades" to "transparent clors", for dark mode

var settings = {};
settings.applyToLinkText = true;
settings.rex = "<title([^>]*)>(.*?)<\\/title>";
settings.dontLookupExtensions = "pdf";
settings.ignoreShorter = true;
settings.ignoreTitles = "^Just a moment...\n^Continue$\n^Untitled$\n^Please wait$\n^Redirecting$\n^Watch$\n/^(log|sign )in$\n^Reddit - Dive into anything/i";
settings.verbosity = 1;

var logger = new BPLogger(GM_info.script.name);

if("undefined" != typeof GM_config){
	GM_config.init(
	{
		'id': 'MyConfig',
		'title': GM_info.script.name + ' Settings',
		'fields': {
			'applyToLinkText': {
				'label': '<b>Replace the actual <i>link text</i> with any found title</b><br /> <i>ON:</i> Change innerHTML of links and applying overflow: visible to parents <br /><i>OFF:</i> Only apply to Link Title (for mouseover Tooltip) <br /><i class="small">Default: ON</i> ',
				'type': 'checkbox',
				'default': true
			},
			'rex': {
				'label': '<b>Regex to find the title of a page.</b> If you find a better way, please <a href="https://greasyfork.org/en/scripts/27406-retrieve-full-page-titles-in-google-search/feedback" target="_blank">let me know</a>! <br /><i class="small">Default: "<title([^>]*)>(.*?)<\\/title>"</i>',
				'type': 'text',
				'default': "<title([^>]*)>(.*?)<\\/title>"
			},
			'dontLookupExtensions': {
				'label': '<b>Exclude file extensions from lookup.</b> For example, links to .pdf file will usually trigger a downlaod when following the link with a GET request. <br /><i class="small">Separate with commas. Default: "pdf',
				'type': 'text',
				'default': "pdf"
			},
			'ignoreShorter': {
				'label': '<b>Ignore new titles that are shorter than the old ones.</b> Titles shorter than the originally truncated ones are probably not what we want. <br /><i class="small">Default: ON</i>',
				'type': 'checkbox',
				'default': true
			},
			'ignoreTitles': {
				'label': '<b>Ignore looked up titles when they return one of these.</b> Usually only needed when the above setting is OFF. <br /><i class="small">Can be RegEx by using /slash delimiters/. Default: <br />Just a moment...<br />Continue<br />Please wait<br />Redirecting<br />Watch<br />/^(log|sign )in$/i</i>',
				'type': 'textarea',
				'default': "Just a moment...\nContinue\nPlease wait\nRedirecting\nWatch\n/^(log|sign )in$/i"
			},
			'verbosity': {
				'label': '<b>Console logging verbosity.</b> 0 = no logs; 1 = reports on link counts; 2 = +statuses of link checks; 3 = +Details <br /><i class="small">Default: 1</i>',
				'type': 'int',
				'default': 1
			}
		},
		'css': `
		#MyConfig{
			background: #333;
			color: #ccc;
			line-height: 1.33em;
		}
		#MyConfig input[type="text"],
		#MyConfig input[type="email"],
		#MyConfig input[type="number"],
		#MyConfig input[type="password"],
		#MyConfig input[type="url"],
		#MyConfig input[type="submit"],
		#MyConfig input[type="reset"],
		#MyConfig input[type="button"],
		#MyConfig select,
		#MyConfig option,
		#MyConfig textarea
		{
			box-sizing: border-box;
			background: #1a1a1a;
			padding: 0.8em;
			color: #fff;
			vertical-align: bottom;
		}
		#MyConfig input[type="text"],
		#MyConfig input[type="email"],
		#MyConfig input[type="number"],
		#MyConfig input[type="password"],
		#MyConfig input[type="url"],
		#MyConfig textarea
		{
			width: 100%;
			border: 2px inset rgba(20,108,128,0.5);
			border-radius: 0.1em;
		}
		#MyConfig input:focus,
		#MyConfig textarea:focus
		{
			color: #fff;
			background: #000;
		}
		#MyConfig textarea
		{
			min-height: 6em;
		}
		#MyConfig .field_label{
			font-weight: normal;
			font-size: 15px;
		}
		#MyConfig kbd{
			border: 1px solid rgba(128,128,128,0.5);
			background: rgba(128,128,128,0.2);
			border-radius: 3px;
			padding: 1px 3px;
			font-family: consolas, monospace;
		}
		#MyConfig .code{
			background: rgba(128,128,128,0.1);
			padding: 1px 3px;
			font-family: consolas, monospace;
		}
		#MyConfig .field_label .small{
			font-size: 80%;
		}
		#MyConfig .config_header{
			margin: 1em 0;
		}
		#MyConfig .config_var{
			border: 1px solid rgba(128,128,128,0.5);
			padding: 1em;
			margin: 0.5em 0 0;
			border-radius: 0.5em;
		}
		#MyConfig a{
			color: #fff;
		}
		#MyConfig .reset, #MyConfig .reset a, #MyConfig_buttons_holder {
			color: #aaa;
		}
		#MyConfig button, #MyConfig .button,
		#MyConfig input[type="submit"],
		#MyConfig input[type="reset"],
		#MyConfig input[type="button"],
		#MyConfig .saveclose_buttons{
			background: rgba(40, 130,180,0.7);
			color: #ddd;
			border: 1px solid transparent;
			cursor: pointer;
			padding: 0.5em 1em;
			border-radius: 0.75em;
			transition: all 0.5s;
		}
		#MyConfig button:hover, #MyConfig .button:hover,
		#MyConfig input[type="submit"]:hover,
		#MyConfig input[type="reset"]:hover,
		#MyConfig input[type="button"]:hover{
			background: rgba(0, 70,100,1);
			color: #fff;
			border-color: rgba(40, 210,255,1);
			border-radius: 0;
		}
		`, 
		events: {
			init: main
		}
	});
}
else{
	log("Could not load GM_config! external resource may be temporarily down?\nUsing default settings for now.", 1, "error");
	
	GM_registerMenuCommand(GM_info.script.name + ' Settings', function(){
		alert("Could not load GM_config! external resource may be temporarily down?\nUsing default settings for now.");
		main(false);
		// TODO: nicer message
		// TODO: Manual lookup
	});
}

logger.level = settings.verbosity;

var myVersion = GM_info.script.version;
var settingsSafety = settings;
var linkmatch = "#search #rso .g a h3";
var resultsObserver;
var idle = true;
var idletimer;
var updaterequest = false;
var openRequests = 0;
var successRequests = 0;
var failedRequests = 0;
var msgPrefix = "Full Page Titles in Google Search:\n";

var lastCount = null;

function log(obj, level = 1, type = "default"){
	if(!(obj instanceof Array))
		obj = [obj];
	logger.writeLog(obj, type, level);
}

log("Verbosity level: " + settings.verbosity, 1);

GM_addStyle(`
/* // Legacy, old version. 
#rso div.g{
	width:auto!important;
}
.srp {
    --center-column: auto;
    --center-column-width: auto;
	--center-width: auto;
}
*/
.showverflow{
	overflow: visible!important;
	contain: layout!important;
}
.imgFix{
    position: relative;
    top: 4em;
}
.titlesChecking{
	background-color: rgba(255, 200, 0, 0.1);
}
.titlesFail{
	background-color: rgba(255, 0, 0, 0.1);
}
.titlesOK{
	background-color: rgba(30, 255, 0, 0.1);
}
`);

var rexRex = /\/(.+)\/([dgimsuy]*)/;

for(let i=0; i<settings.ignoreTitles.length; i++){
	let x = settings.ignoreTitles[i];
	let m = rexRex.exec(x);
	if(m && m.length>1){
		try{
			let rex = new RegExp(m[1], m[2]);
			settings.ignoreTitles[i] = rex;
		}
		catch(e){
			log("Not a valid regular expression in settings.ignoreTitles: " + x, 1, "error");
		}
	}
}

function updatePage(){
    var allinks = 0;
    log("Walking through Links...", 2);
	$(linkmatch + ":not([titleDone], .titlesChecking)").each(function(){
        allinks ++;
//        log(this, 3);
//        log("Looking at Link '" + $(this).parent()[0].href + "' (" + this.innerHTML + ")", 3);
		if(this.textContent.substr(this.textContent.length-3)=="..."){
			log("'" + this.textContent + "' Needs checking", 2);
			$(this).addClass("titlesChecking");
			getTitle(this);
		}
	});
	if(lastCount !== openRequests)
		log(openRequests + " of " + allinks + " links need to be checked.", 1);
	lastCount = openRequests;
}

function getTitle(el){
	var a = $(el).closest("a")[0];
    log("Title '" + a.href + "' (" + el.textContent + ") looks shortened.", 3);
	for(var i=0; i<settings.dontLookupExtensions.length; i++)
	{
		if(a.href.endson(settings.dontLookupExtensions[i], true)){
            log("Excluding Link '" + a.href + "' (" + el.textContent + ") because the extension (" + settings.dontLookupExtensions[i] + "), which is excluded", 3);
			el.title = "The extension " + settings.dontLookupExtensions[i] + " is excluded in settings";
			$(el).removeClass("titlesChecking");
			$(el).addClass("titlesFail");
			$(el).attr("titleDone", "true");
			return;
        }
	}
    openRequests++;
	$(el).addClass("titlesChecking");
	GM_xmlhttpRequest({
		url: a.href,
		method: "GET",
		timeout: 15000, //15 seconds timeout
		onload: function(res){
			$(el).attr("titleDone", "true");
			$(el).removeClass("titlesChecking");
            var mrex = new RegExp(settings.rex, "i");
			var tit = mrex.exec(res.response);
			if(tit === undefined || tit === null){
                log("No title found in response for " + a.href, 2, "error");
				el.title = "Failed to get title: No title found in response";
				$(el).addClass("titlesFail");
                report("fail");
				return;
            }
			tit = unEscapeHtml(tit[2]);
			var oldTitle = $(el).text().trim();
			if(settings.ignoreShorter && tit.length < oldTitle.length-3){
				log("Ignoring title '" + tit + "' because it is shorter than the original (" + oldTitle + ")", 3, "warn");
				el.title = "(Ignore:) " + tit;
				$(el).addClass("titlesFail");
				report("success");
				return;
			}
			for(let x of settings.ignoreTitles){
				if((x instanceof RegExp && tit.match(x)) || (typeof x == "string" && x.toLowerCase() == tit.toLowerCase())){
					log(["Ignoring title '" + tit + "' because of the excludion rule", x, "specified in settings"], 3, "warn");
					el.title = "(Ignore:) " + tit;
					$(el).addClass("titlesFail");
					report("success");
					return;
				}
			}
			$(el).addClass("titlesOK");
            if(settings.applyToLinkText){
                $(el).text(tit);
				el.title = tit + "\n\nOriginal Title: " + oldTitle;
                $(a).css("white-space", "nowrap");
                $(a).parentsUntil(".g").last().find("[data-content-feature] img").parents("[data-content-feature]").first().addClass("imgFix");
                $(a).parentsUntil("#main", ":not(.showverflow)").addClass("showverflow");
            }
			else
				el.title = tit;
            report("success");
		},
		onerror: function(res){
			$(el).attr("titleDone", "true");
			$(el).removeClass("titlesChecking");
			$(el).addClass("titlesFail");
			el.title = "Failed to get full title: Error loading page";
			log({error: "Error loading page", link: el}, 2, "error");
            report("fail");
		},
		ontimeout: function(res){
			$(el).attr("titleDone", "true");
			$(el).removeClass("titlesChecking");
			$(el).addClass("titlesFail");
			el.title = "Failed to get full title: Connection timed out";
			log({error: "Connection timed out", link: el}, 2, "error");
            report("fail");
		}
	});
}

function report(status){
    switch(status){
        case "success":
            successRequests ++;
            openRequests --;
            break;
        case "fail":
            failedRequests ++;
            openRequests --;
            break;
    }
    log(successRequests + " requests successful, " + failedRequests + " failed. " + openRequests + " Requests open.", 1);
}

function unEscapeHtml(text){
	var t = document.createElement("TEXTAREA");
	t.innerHTML = text;
	return t.value;
}

String.prototype.endson = function(str, insensitive){
  return new RegExp("("+escapeRegExp(str)+")$", insensitive?"i":"").test(this);
};

function escapeRegExp(str) {
	return str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
}

function updater(t = 1000){
	if(idle)
	{
		idle = false;
		updaterequest = false;
		updatePage();
		idletimer = setTimeout(function(){
			idle = true;
			if(updaterequest)
				updatePage();
		}, t);
	}
	else
	{
        log("Updater called but busy",3);
		updaterequest = true;
	}
}

function main(hasSettings=true){
	if(hasSettings){
		GM_registerMenuCommand(GM_info.script.name + ' Settings', function(){
			GM_config.open();
		});

		settings.applyToLinkText = GM_config.get("applyToLinkText");
		settings.rex = GM_config.get("rex");
		settings.dontLookupExtensions = GM_config.get("dontLookupExtensions").split(",").map(a=>"." + a.trim());
		settings.ignoreShorter = GM_config.get("ignoreShorter");
		settings.ignoreTitles = GM_config.get("ignoreTitles").split(/\r?\n/);
		settings.verbosity = GM_config.get("verbosity");
	}
	log("Start updater interval", 3);
	setInterval(updater, 2000);
}

/* jshint loopfunc: true, -W027 */
/* eslint-disable curly, no-redeclare */
/* eslint no-trailing-spaces: off */
/* globals $, GM_info, GM_setValue, GM_getValue, GM_xmlhttpRequest, GM_addStyle, GM_openInTab, GM_setClipboard, GM_config, escape, uneval, unsafeWindow, BPLogger_default, log, error, warn, getParam, waitFor, BPLogger */