您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.
// ==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) {} }); })();