// ==UserScript==
// @name Google - Full result titles
// @namespace http://tampermonkey.net/
// @version 0.1
// @description Expands truncated search result titles, using a simple clever fetching method. Also adds tooltips to see the full title on "top stories" and "what people are saying" ('title' attribute, no fetching necessary). If you disable auto expansion, you can hold Shift to fetch per-title, Shift+T expands all.
// @author jackiechan285
// @match https://www.google.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=google.com
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-idle
// ==/UserScript==
(() => {
'use strict';
/************* CONFIG *************/
const FETCH_TIMEOUT = 10000; // ms
const MIN_BASE_LEN = 5; // ignore very short bases
const AUTO_EXPAND = true; // auto-expand cut-off titles on load
const AUTO_UNDERLINE = false; // underline revealed part for auto-expanded (removed permanently when user presses Shift once)
const AUTO_UNDERLINE_STYLE = 'underline';
const REVEAL = { enabled: true, color: '#FF9AA2', transition: 'color .12s ease' };
const TOP_STORIES_ANCESTOR_SELECTORS = ['.SoAPf', '.lSfe4c', 'g-scrolling-carousel'];
const PEOPLE_SAYING_ANCESTOR_SELECTORS = ['.JJJtgd', '.reMwfc', '.g1y0jd'];
const EXTRA_TITLE_SELECTORS = ['h3', '[role="heading"][aria-level]', '.tNxQIb', '.ynAwRc', '.cHaqb', 'shreddit-title'];
/***********************************/
// state
const cache = new Map(); // href -> {status:'ok'|'notfound'|'error' , title?, msg?}
let hoveredEl = null;
let isShiftDown = false;
let lastMouse = { x: 0, y: 0 };
let underlinePermanentlyRemoved = false;
let autoUnderlineEnabled = AUTO_UNDERLINE;
// tooltip
const tooltip = document.createElement('div');
Object.assign(tooltip.style, {
position: 'fixed', zIndex: 2147483647, padding: '6px 8px', fontSize: '12px',
borderRadius: '6px', background: 'rgba(32,33,36,0.95)', color: '#fff',
boxShadow: '0 2px 10px rgba(0,0,0,0.4)', pointerEvents: 'none', display: 'none',
maxWidth: '420px', whiteSpace: 'pre-wrap'
});
document.body.appendChild(tooltip);
function showTooltipAt(text, x, y) {
tooltip.textContent = text;
tooltip.style.left = (x + 12) + 'px';
tooltip.style.top = (y + 12) + 'px';
tooltip.style.display = 'block';
}
function showTooltipNearElement(text, el) {
try {
const rect = el.getBoundingClientRect();
const x = rect.right;
const y = rect.top;
showTooltipAt(text, Math.max(4, x), Math.max(4, y));
} catch (e) {
showTooltipAt(text, lastMouse.x, lastMouse.y);
}
}
function hideTooltip() { tooltip.style.display = 'none'; tooltip.textContent = ''; }
// mouse position
document.addEventListener('mousemove', ev => {
lastMouse.x = ev.clientX; lastMouse.y = ev.clientY;
if (hoveredEl) {
// don't show tooltip for top stories / people-saying candidates
if (isTopStoryCandidate(hoveredEl) || isPeopleSayingCandidate(hoveredEl)) { hideTooltip(); return; }
if (hoveredEl.dataset.gsReplaced === '1') { hideTooltip(); return; }
const status = hoveredEl.dataset.gsStatus || 'Hold Shift to fetch full title';
showTooltipAt(status, ev.clientX, ev.clientY);
}
});
// keyboard
document.addEventListener('keydown', ev => {
const tg = ev.target;
const isTyping = tg && (tg.tagName === 'INPUT' || tg.tagName === 'TEXTAREA' || tg.isContentEditable);
if (ev.key === 'Shift' && !isShiftDown) {
isShiftDown = true;
// if hovering an unfinished title, trigger fetch
if (hoveredEl && hoveredEl.dataset && hoveredEl.dataset.gsReplaced !== '1' &&
!isTopStoryCandidate(hoveredEl) && !isPeopleSayingCandidate(hoveredEl)) {
triggerFetchFor(hoveredEl);
}
if (REVEAL.enabled) highlightAllRevealed(true);
// Remove auto-underlines permanently once (per your request)
if (AUTO_UNDERLINE && !underlinePermanentlyRemoved) {
underlinePermanentlyRemoved = true;
autoUnderlineEnabled = false;
document.querySelectorAll('.gs-auto-underline').forEach(el => el.classList.remove('gs-auto-underline'));
}
return;
}
if (!isTyping && (ev.key === 'T' || ev.key === 't') && ev.shiftKey) {
ev.preventDefault();
expandAll();
return;
}
});
document.addEventListener('keyup', ev => {
if (ev.key === 'Shift') {
isShiftDown = false;
if (REVEAL.enabled) highlightAllRevealed(false);
}
});
// helpers
function normalizeWhitespace(s) {
return String(s || '').replace(/\s+/g, ' ').trim();
}
function stripTrailingEllipsis(s) {
return String(s || '').replace(/(\u2026|\.\.\.|\s+\.\.\.)\s*$/, '').trim();
}
function decodeHTMLEntities(str) {
const txt = document.createElement('textarea');
txt.innerHTML = str;
return txt.value;
}
function normalizeChars(str) {
str = decodeHTMLEntities(str);
return str
.replace(/[‘’‚‛]/g, "'") // normalize single quotes
.replace(/[“”„‟]/g, '"') // normalize double quotes
.replace(/–/g, '-') // en-dash
.replace(/—/g, '-') // em-dash
.replace(/…/g, '...') // ellipsis
.replace(/\u00A0/g, ' '); // non-breaking space
}
function normalizeForCompare(s) {
//return normalizeChars(normalizeWhitespace(s)).toLowerCase();
return normalizeChars(normalizeWhitespace(s));
}
function isRedditHref(href) {
try { return (new URL(href)).host.includes('reddit.'); } catch (e) { return false; }
}
function isTopStoryCandidate(el) {
if (!el) return false;
for (const sel of TOP_STORIES_ANCESTOR_SELECTORS) { try { if (el.closest && el.closest(sel)) return true; } catch(e){} }
return false;
}
function isPeopleSayingCandidate(el) {
if (!el) return false;
for (const sel of PEOPLE_SAYING_ANCESTOR_SELECTORS) { try { if (el.closest && el.closest(sel)) return true; } catch(e){} }
return false;
}
// look for base inside full (flexible)
function findBaseInFull(full, base) {
if (!full || !base) return null;
// normalize both sides for consistent matching
const fullNorm = normalizeForCompare(full);
const baseNorm = normalizeForCompare(base);
const idxDirect = fullNorm.indexOf(baseNorm);
if (idxDirect >= 0) return { start: idxDirect, len: baseNorm.length };
// fallback regex with normalized whitespace
const esc = baseNorm.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&').replace(/\s+/g, '\\s+');
try {
const re = new RegExp(esc, 'i');
const m = re.exec(fullNorm);
if (m) return { start: m.index, len: m[0].length };
} catch (err) {}
return null;
}
// robust title extraction from HTML doc with many fallbacks
function findTitleInDoc(doc, baseNormalized) {
try {
const tTag = doc.querySelector && doc.querySelector('title');
if (tTag && tTag.textContent) {
const tt = normalizeWhitespace(tTag.textContent);
if (normalizeForCompare(tt).includes(baseNormalized)) return tt;
}
} catch (e) {}
try {
const h1s = Array.from(doc.getElementsByTagName('h1') || []);
for (const h1 of h1s) {
const t = normalizeWhitespace(h1.textContent || '');
if (!t) continue;
if (normalizeForCompare(t).includes(baseNormalized)) return t;
// aria-label fallback
const al = (h1.getAttribute && h1.getAttribute('aria-label')) || '';
if (al && normalizeForCompare(al).includes(baseNormalized)) return normalizeWhitespace(al);
}
} catch (e) {}
try {
const h2s = Array.from(doc.getElementsByTagName('h2') || []);
for (const h2 of h2s) {
const t = normalizeWhitespace(h2.textContent || '');
if (!t) continue;
if (normalizeForCompare(t).includes(baseNormalized)) return t;
}
} catch (e) {}
try {
const og = doc.querySelector('meta[property="og:title"], meta[name="og:title"], meta[name="twitter:title"]');
if (og && (og.content || og.getAttribute('content'))) {
const t = normalizeWhitespace(og.content || og.getAttribute('content'));
if (normalizeForCompare(t).includes(baseNormalized)) return t;
}
} catch (e) {}
// heuristic: elements with id/class containing 'post-title' or 'title' or 'headline'
try {
const candidates = Array.from(doc.querySelectorAll('[id],[class]'));
for (const c of candidates) {
const id = (c.id || '').toLowerCase();
const cls = (c.className || '').toString().toLowerCase();
if (id.includes('post-title') || cls.includes('post-title') || cls.includes('post-title') || cls.includes('headline') || cls.includes('post')) {
const t = normalizeWhitespace(c.textContent || '');
if (t && normalizeForCompare(t).includes(baseNormalized)) return t;
// aria-label fallback
const al = (c.getAttribute && c.getAttribute('aria-label')) || '';
if (al && normalizeForCompare(al).includes(baseNormalized)) return normalizeWhitespace(al);
}
}
} catch (e) {}
// last resort: loose text search in significant text nodes
try {
const textNodes = Array.from(doc.querySelectorAll('p,div,span'));
for (const node of textNodes) {
const t = normalizeWhitespace(node.textContent || '');
if (!t) continue;
if (t.length > baseNormalized.length && normalizeForCompare(t).includes(baseNormalized)) return t;
}
} catch (e) {}
return null;
}
// set status on element and show tooltip near element on failures (immediate)
function setStatus(el, txt, showNow=false) {
if (!el) return;
el.dataset.gsStatus = txt;
//if (showNow) showTooltipNearElement(txt, el);
/*else*/ if (el === hoveredEl && el.dataset.gsReplaced !== '1') showTooltipAt(txt, lastMouse.x, lastMouse.y);
}
// attempt to fetch reddit JSON (fast and reliable for post title)
function fetchRedditJson(h3, href, baseTitle, cb) {
try {
let jsonUrl = href;
// normalize: if there's a query/hash, remove them for .json append
const u = new URL(href);
// ensure path ends with '/'
if (!u.pathname.endsWith('/')) u.pathname += '/';
jsonUrl = `${u.origin}${u.pathname}.json`;
GM_xmlhttpRequest({
method: 'GET', url: jsonUrl, timeout: FETCH_TIMEOUT,
onload(resp) {
try {
const parsed = JSON.parse(resp.responseText);
// Reddit posts return an array; title usually at [0].data.children[0].data.title
const t = parsed && parsed[0] && parsed[0].data && parsed[0].data.children && parsed[0].data.children[0] && parsed[0].data.children[0].data && parsed[0].data.children[0].data.title;
if (t && normalizeForCompare(t).includes(normalizeForCompare(baseTitle))) {
cb(null, t);
return;
}
// else fallback to HTML parsing by signaling "notfound"
cb(new Error('no-title-in-json'), null);
} catch (err) {
cb(err, null);
}
},
onerror(err) { cb(new Error('network error'), null); },
ontimeout() { cb(new Error('timeout'), null); }
});
} catch (err) {
cb(err, null);
}
}
// primary fetch routine (prefers reddit JSON when reddit link)
function fetchAndProcess(h3, href, baseTitle) {
const baseNormalized = normalizeForCompare(baseTitle);
if (!baseNormalized || baseTitle.length < MIN_BASE_LEN) {
setStatus(h3, 'Title too short to fetch full title.', true);
return;
}
// If we previously cached an OK result, reuse it. If cached error, allow retry by clearing cache for this href.
if (cache.has(href) && cache.get(href).status === 'ok') {
applyCacheResult(h3, cache.get(href), baseTitle);
return;
}
if (cache.has(href) && cache.get(href).status !== 'ok') {
cache.delete(href); // allow retry attempts
}
setStatus(h3, 'Fetching full title...');
cache.set(href, { status: 'fetching' });
// If reddit, try .json first (more reliable)
if (isRedditHref(href)) {
fetchRedditJson(h3, href, baseTitle, (err, titleFromJson) => {
if (!err && titleFromJson) {
const res = { status: 'ok', title: normalizeForCompare(titleFromJson) };
cache.set(href, res);
applyCacheResult(h3, res, baseTitle);
return;
}
// else fallback to normal HTML fetch (try HTML parsing)
doHtmlFetch();
});
} else {
doHtmlFetch();
}
function doHtmlFetch() {
GM_xmlhttpRequest({
method: 'GET', url: href, timeout: FETCH_TIMEOUT,
onload(resp) {
try {
// If the response looks like JSON even though not reddit, attempt to parse JSON
const txt = resp.responseText || '';
const firstNonWs = txt.trim().charAt(0);
if (firstNonWs === '{' || firstNonWs === '[') {
try {
const parsed = JSON.parse(txt);
// If some sites return JSON with useful title fields, attempt to extract probable fields
let candidate = parsed.title || parsed.name || (parsed.data && parsed.data.title) || null;
if (candidate && normalizeForCompare(candidate).includes(baseNormalized)) {
const res = { status: 'ok', title: normalizeForCompare(candidate) };
cache.set(href, res);
applyCacheResult(h3, res, baseTitle);
return;
}
// else continue to HTML parsing fallback below
} catch (e) {
// ignore JSON parse error and continue to HTML parsing
}
}
// Try to parse HTML and extract title with many fallbacks
const parser = new DOMParser();
const doc = parser.parseFromString(txt, 'text/html');
const found = findTitleInDoc(doc, baseNormalized);
if (found) {
const res = { status: 'ok', title: normalizeForCompare(found) };
cache.set(href, res);
applyCacheResult(h3, res, baseTitle);
} else {
const res = { status: 'notfound', msg: 'No matching title/h1/h2/meta/aria found in HTML.' };
cache.set(href, res);
applyCacheResult(h3, res, baseTitle);
}
} catch (err) {
const res = { status: 'error', msg: 'Parse error: ' + (err && err.message ? err.message : err) };
cache.set(href, res);
applyCacheResult(h3, res, baseTitle);
}
},
onerror() {
const res = { status: 'error', msg: 'Network error' };
cache.set(href, res);
applyCacheResult(h3, res, baseTitle);
},
ontimeout() {
const res = { status: 'error', msg: 'Request timed out' };
cache.set(href, res);
applyCacheResult(h3, res, baseTitle);
}
});
}
}
// apply results (ok / notfound / error)
function applyCacheResult(h3, cached, baseTitle) {
if (!h3) return;
if (cached.status === 'fetching') { setStatus(h3, 'Still fetching...'); return; }
if (cached.status === 'ok') {
const full = cached.title || '';
const base = stripTrailingEllipsis(baseTitle || '');
const match = findBaseInFull(full, base);
let prefix = full, suffix = '';
if (match) { prefix = full.substring(0, match.start + match.len); suffix = full.substring(match.start + match.len); }
else { prefix = full; suffix = ''; }
const esc = s => String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
h3.innerHTML = `<span class="gs-full-prefix">${esc(prefix)}</span><span class="gs-full-suffix">${esc(suffix)}</span>`;
h3.dataset.gsFullTitle = full;
h3.dataset.gsBaseTitle = base;
h3.dataset.gsReplaced = '1';
ensureStylesInjected();
// mark auto-underlines only if this came from AUTO_EXPAND and autoUnderlineEnabled is true
if (h3._gsAutoExpanded && autoUnderlineEnabled) h3.classList.add('gs-auto-underline');
setStatus(h3, `Replaced — full title found (+${full.length - (baseTitle||'').length} chars).`);
if (hoveredEl === h3) hideTooltip();
// reveal on hover
if (REVEAL.enabled) {
const suffixNode = h3.querySelector('.gs-full-suffix');
if (suffixNode) {
suffixNode.style.transition = REVEAL.transition;
suffixNode.style.color = 'inherit';
}
h3.addEventListener('mouseenter', onHoverReveal);
h3.addEventListener('mouseleave', onLeaveReveal);
h3.addEventListener('focus', onHoverReveal, true);
h3.addEventListener('blur', onLeaveReveal, true);
}
// clear any failure flag
delete h3.dataset.gsFailed;
} else {
// notfound or error
const message = cached.msg || 'Unknown error';
setStatus(h3, `Failed: ${message}`, true); // show tooltip immediately near element
h3.dataset.gsFailed = '1';
// make sure retry possible by clicking (see attach handler)
}
}
function onHoverReveal(ev) {
const el = ev.currentTarget;
const suffix = el.querySelector('.gs-full-suffix');
if (suffix) suffix.style.color = REVEAL.color;
}
function onLeaveReveal(ev) {
const el = ev.currentTarget;
const suffix = el.querySelector('.gs-full-suffix');
if (suffix) suffix.style.color = 'inherit';
}
// trigger fetch (respects caching for OK; clears cache on previous errors allowing retry)
function triggerFetchFor(h3) {
if (!shouldAttach(h3)) return;
if (isTopStoryCandidate(h3) || isPeopleSayingCandidate(h3)) return; // they don't fetch
const a = h3.closest('a[href]');
if (!a) { setStatus(h3, 'No link target found for this title.', true); return; }
const href = a.href;
const rawTitle = (h3.textContent || '').trim();
const base = stripTrailingEllipsis(rawTitle);
// if previously failed and cached as not ok, clear entry to allow fresh attempt
if (cache.has(href) && cache.get(href).status !== 'ok') cache.delete(href);
h3.dataset.gsFetchTried = '1';
fetchAndProcess(h3, href, base);
}
// attach to candidate elements
function attachTo(h3) {
if (!h3 || h3.dataset.gsAttached === '1') return;
h3.dataset.gsAttached = '1';
// special shrreddit-title handling: prefer provided title attribute
if (h3.tagName && h3.tagName.toLowerCase() === 'shreddit-title') {
try {
const t = h3.getAttribute('title');
if (t) {
h3.textContent = t;
h3.dataset.gsFullTitle = t;
h3.dataset.gsBaseTitle = t;
h3.dataset.gsReplaced = '1';
ensureStylesInjected();
}
} catch (e) {}
return;
}
// mouseenter / leave
h3.addEventListener('mouseenter', ev => {
hoveredEl = h3;
// top stories / people-saying: only add native title attribute; no tooltip and no fetch
if (isTopStoryCandidate(h3) || isPeopleSayingCandidate(h3)) {
try { if (!h3.getAttribute('title')) h3.setAttribute('title', normalizeWhitespace(h3.textContent || '')); } catch(e){}
hideTooltip(); return;
}
if (h3.dataset.gsReplaced === '1') { hideTooltip(); return; }
const raw = (h3.textContent || '').trim();
const base = stripTrailingEllipsis(raw);
const hint = base.length >= MIN_BASE_LEN ? 'Hold Shift to fetch full title' : 'Title too short to fetch';
setStatus(h3, hint);
showTooltipAt(h3.dataset.gsStatus || hint, ev.clientX, ev.clientY);
if (ev.shiftKey) triggerFetchFor(h3);
});
h3.addEventListener('mouseleave', () => { if (hoveredEl === h3) hoveredEl = null; hideTooltip(); });
// click behavior:
// - If previously failed -> single-click retries (prevents navigation)
// - Otherwise, Shift+click retries (prevents navigation)
h3.addEventListener('click', ev => {
const failed = h3.dataset.gsFailed === '1';
const isTopOrPeople = isTopStoryCandidate(h3) || isPeopleSayingCandidate(h3);
if (failed && !isTopOrPeople) {
ev.preventDefault();
// clear cache for this href (if any) and retry
const a = h3.closest('a[href]'); if (!a) return;
const href = a.href; if (cache.has(href) && cache.get(href).status !== 'ok') cache.delete(href);
triggerFetchFor(h3);
return;
}
if (ev.shiftKey && !isTopOrPeople) {
ev.preventDefault();
triggerFetchFor(h3);
return;
}
// otherwise, allow navigation normally (user may ctrl/cmd click to open in new tab)
});
}
function shouldAttach(h3) {
if (!h3 || !(h3 instanceof HTMLElement)) return false;
// shrreddit-title always attach
if (h3.tagName && h3.tagName.toLowerCase() === 'shreddit-title') return true;
const a = h3.closest && h3.closest('a[href]');
if (!a) {
// allow top stories / people-saying to attach even without link
if (!isTopStoryCandidate(h3) && !isPeopleSayingCandidate(h3)) return false;
}
const txt = (h3.textContent || '').trim(); if (!txt) return false;
if (isTopStoryCandidate(h3) || isPeopleSayingCandidate(h3)) return true;
// only attach to cut-off titles (end with ellipsis)
if (/(\u2026|\.\.\.)\s*$/.test(txt)) return true;
return false;
}
// scanning & mutation
const selector = EXTRA_TITLE_SELECTORS.join(',');
function scanAndAttach(root = document) {
try {
const nodes = root.querySelectorAll ? Array.from(root.querySelectorAll(selector)) : [];
nodes.forEach(node => { if (shouldAttach(node)) attachTo(node); });
} catch (e) {}
}
const mo = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.addedNodes && m.addedNodes.length) {
m.addedNodes.forEach(n => {
if (n.nodeType !== 1) return;
scanAndAttach(n);
if (AUTO_EXPAND) {
try {
const nodes = Array.from(n.querySelectorAll ? n.querySelectorAll(selector) : []);
nodes.forEach(node => { if (shouldAttach(node)) autoExpandNode(node); });
} catch (e) {}
}
});
}
}
});
mo.observe(document.documentElement || document.body, { childList: true, subtree: true });
// styles and reveal/underline support
let stylesInjected = false;
function ensureStylesInjected() {
if (stylesInjected) return;
stylesInjected = true;
const css = `
.gs-full-prefix { white-space: normal; }
.gs-full-suffix { white-space: normal; color: inherit; transition: ${REVEAL.transition}; }
.gs-auto-underline .gs-full-suffix { text-decoration: ${AUTO_UNDERLINE_STYLE}; text-decoration-thickness: 1px; text-decoration-color: currentColor; }
`;
const st = document.createElement('style'); st.textContent = css;
(document.head || document.documentElement).appendChild(st);
}
// highlight all expanded suffixes (used on Shift)
function highlightAllRevealed(on) {
const suffixes = Array.from(document.querySelectorAll('.gs-full-suffix'));
suffixes.forEach(s => { s.style.color = on ? REVEAL.color : 'inherit'; });
}
// expand all (Shift+T)
function expandAll() {
const allCandidates = Array.from(document.querySelectorAll(selector));
const targets = allCandidates.filter(h3 => shouldAttach(h3) && !isTopStoryCandidate(h3) && !isPeopleSayingCandidate(h3));
targets.forEach(h3 => triggerFetchFor(h3));
}
// auto-expand behavior
function autoExpandNode(h3) {
if (!h3) return;
if (isTopStoryCandidate(h3) || isPeopleSayingCandidate(h3)) {
try { if (!h3.getAttribute('title')) h3.setAttribute('title', normalizeWhitespace(h3.textContent || '')); } catch(e){}
return;
}
if (h3.tagName && h3.tagName.toLowerCase() === 'shreddit-title') {
try {
const t = h3.getAttribute('title');
if (t) { h3.textContent = t; h3.dataset.gsFullTitle = t; h3.dataset.gsBaseTitle = t; h3.dataset.gsReplaced = '1'; ensureStylesInjected(); }
} catch(e){}
return;
}
h3._gsAutoExpanded = true;
triggerFetchFor(h3);
}
// initial scan + auto expand
scanAndAttach();
ensureStylesInjected();
if (AUTO_EXPAND) {
setTimeout(() => {
scanAndAttach();
const candidates = Array.from(document.querySelectorAll(selector)).filter(n => shouldAttach(n));
candidates.forEach(n => autoExpandNode(n));
}, 800);
}
// clean up
window.addEventListener('beforeunload', () => { try { tooltip.remove(); } catch (e) {} });
})();