// ==UserScript== // @name 网页元素探测脚本 // @namespace http://tampermonkey.net/ // @version 3.5 // @description 全站可用的页面元素探测工具:悬浮按钮一键扫描任意网页的全部元素(含 Shadow DOM),输出标签、尺寸、坐标、CSS 路径与关键样式,支持筛选、复制、保存为 txt;内置「诊断顶部」快速定位吸顶遮挡元素,并在原生全屏态下自动迁移 UI 以避免探针消失。 // @author YourName // @match *://*/* // @grant none // @run-at document-end // @license All Rights Reserved // ==/UserScript== (function () { 'use strict'; // ================= 工具函数 ================= function shortCls(el, n) { if (!el.classList || !el.classList.length) return ''; return Array.from(el.classList) .filter(c => c && c.length < 60 && !/^\d/.test(c)) .slice(0, n || 6) .join('.'); } function cssPath(el, shadowHost) { const parts = []; let cur = el, depth = 0; while (cur && cur.nodeType === 1 && depth < 6) { let seg = cur.tagName.toLowerCase(); if (cur.id) { parts.unshift('#' + cur.id); break; } const cls = shortCls(cur, 3); if (cls) seg += '.' + cls; const parent = cur.parentElement; if (parent) { const same = Array.from(parent.children).filter(c => c.tagName === cur.tagName); if (same.length > 1) seg += ':nth-of-type(' + (same.indexOf(cur) + 1) + ')'; } parts.unshift(seg); cur = cur.parentElement; depth++; if (shadowHost && cur === shadowHost) break; } const inner = parts.join('>'); if (shadowHost) { const hostDesc = '<' + shadowHost.tagName.toLowerCase() + '>' + (shadowHost.id ? '#' + shadowHost.id : '') + (shortCls(shadowHost, 2) ? '.' + shortCls(shadowHost, 2) : ''); return '[shadow-root of ' + hostDesc + '] > ' + inner; } return inner; } function getText(el) { let t = ''; if (el.children.length === 0) { t = el.textContent || ''; } else { for (const n of el.childNodes) { if (n.nodeType === 3) t += n.textContent; } if (!t.trim()) t = el.innerText || ''; } return t.replace(/\s+/g, ' ').trim().slice(0, 40); } // v3.3 新增:坐标字符串 function rectStr(el) { const r = el.getBoundingClientRect(); return 'top=' + Math.round(r.top) + ' bottom=' + Math.round(r.bottom) + ' left=' + Math.round(r.left) + ' w=' + Math.round(r.width) + ' h=' + Math.round(r.height); } // v3.3 新增:关键样式字符串 function styleStr(el) { const s = window.getComputedStyle(el); const tf = (s.transform && s.transform !== 'none') ? s.transform.replace(/\s+/g, '') : '-'; return 'pos=' + s.position + ' z=' + s.zIndex + ' pt=' + s.paddingTop + ' pb=' + s.paddingBottom + ' mt=' + s.marginTop + ' mb=' + s.marginBottom + ' tf=' + tf + ' ovf=' + s.overflow; } // ================= 递归收集元素(含 Shadow DOM) ================= function collectAllElements(includeShadow, root) { const result = []; const seen = new Set(); function walk(node, shadowHost) { if (!node || node.nodeType !== 1) return; if (seen.has(node)) return; seen.add(node); const tag = node.tagName.toLowerCase(); if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'link') return; if (node.id === '__probe_panel' || node.id === '__probe_btn') return; if (node.closest && node.closest('#__probe_panel, #__probe_btn')) return; result.push({ el: node, shadowHost: shadowHost }); for (const child of node.children) { walk(child, shadowHost); } if (includeShadow && node.shadowRoot) { for (const child of node.shadowRoot.children) { walk(child, node); } } } const start = root || document.body; if (start) walk(start, null); return result; } // ================= 扫描 ================= function scanPage(opts) { opts = opts || {}; const minW = opts.minW || 0; const minH = opts.minH || 0; const includeHidden = !!opts.includeHidden; const includeShadow = opts.includeShadow !== false; const withRect = opts.withRect !== false; // v3.3 默认开 const withCss = !!opts.withCss; // v3.3 默认关(省体积) const onlyTop = !!opts.onlyTop; // v3.3 只看顶部 const topLimit = opts.topLimit || 400; // v3.4:root 非空时只扫这棵子树(全屏态用来只列全屏元素内部) const all = collectAllElements(includeShadow, opts.root || null); const lines = []; let idx = 0; all.forEach(({ el, shadowHost }) => { const tag = el.tagName.toLowerCase(); const r = el.getBoundingClientRect(); const s = window.getComputedStyle(el); const hidden = s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0; const w = Math.round(r.width); const h = Math.round(r.height); if (!includeHidden) { if (hidden) return; if (w < minW || h < minH) return; } if (onlyTop && r.top > topLimit) return; const id = el.id ? '#' + el.id : ''; const cls = shortCls(el, 6); const clsStr = cls ? '.' + cls : ''; const path = cssPath(el, shadowHost); const text = getText(el); let line = '[' + idx + '] <' + tag + '>' + id + clsStr + ' [' + w + 'x' + h + ']' + (shadowHost ? ' [shadow]' : '') + (hidden ? ' (hidden)' : ''); if (withRect) line += '\n rect: ' + rectStr(el); if (withCss) line += '\n css : ' + styleStr(el); line += '\n path: ' + path; if (text) line += '\n text: ' + text; lines.push(line); idx++; }); return lines.join('\n'); } // ================= v3.3 新增:顶部诊断 ================= function diagTop() { const sels = [ 'html', 'body', '#app', 'main.space-main', '.space-search', '.search-sidebar', '.search-content', '.space-upload', '.upload-sidenav', '.upload-content', '.nav-bar.space-navbar', '.header.space-header', '.space-theme-bg', '#my-bili-searchbar' ]; const out = []; out.push('=== 顶部诊断 ==='); out.push('viewport: ' + window.innerWidth + 'x' + window.innerHeight + ' scrollY=' + Math.round(window.scrollY)); out.push(''); sels.forEach(function (sel) { const el = document.querySelector(sel); if (!el) { out.push(sel + ' -> (不存在)'); return; } const r = el.getBoundingClientRect(); const s = window.getComputedStyle(el); out.push(sel); out.push(' rect: ' + rectStr(el)); out.push(' css : ' + styleStr(el) + ' disp=' + s.display); }); out.push(''); out.push('=== 覆盖顶部 0~120px 的定位元素(遮挡嫌疑)==='); let n = 0; const all = document.querySelectorAll('body *'); all.forEach(function (el) { const s = window.getComputedStyle(el); if (s.display === 'none' || s.visibility === 'hidden') return; if (s.position !== 'fixed' && s.position !== 'sticky' && s.position !== 'absolute') return; const r = el.getBoundingClientRect(); if (r.width < 4 || r.height < 4) return; if (r.top > 120 || r.bottom < 0) return; n++; out.push(' <' + el.tagName.toLowerCase() + '>' + (el.id ? '#' + el.id : '') + (shortCls(el, 3) ? '.' + shortCls(el, 3) : '') + ' [' + Math.round(r.width) + 'x' + Math.round(r.height) + ']' + ' top=' + Math.round(r.top) + ' bottom=' + Math.round(r.bottom) + ' pos=' + s.position + ' z=' + s.zIndex); }); if (!n) out.push(' (无)'); return out.join('\n'); } // ================= 复制 ================= function copyText(text, onDone) { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then( () => onDone && onDone(true), () => fallbackCopy(text, onDone) ); } else { fallbackCopy(text, onDone); } } function fallbackCopy(text, onDone) { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;'; document.body.appendChild(ta); ta.focus(); ta.select(); ta.setSelectionRange(0, text.length); let ok = false; try { ok = document.execCommand('copy'); } catch (e) { ok = false; } document.body.removeChild(ta); onDone && onDone(ok); } // ================= 保存为 txt ================= function timeStamp() { const d = new Date(); const p = n => String(n).padStart(2, '0'); return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + '_' + p(d.getHours()) + '-' + p(d.getMinutes()) + '-' + p(d.getSeconds()); } function saveAsTxt(filename, text, onResult) { filename = filename || ('probe_' + timeStamp() + '.txt'); tryPicker(filename, text).then(ok => { if (ok) { onResult && onResult('picker'); return; } if (tryAnchor(filename, text)) { onResult && onResult('anchor'); return; } const opened = tryNewWindow(filename, text); copyText(text, () => {}); onResult && onResult(opened ? 'window' : 'copy'); }); } function tryPicker(filename, text) { if (typeof window.showSaveFilePicker !== 'function') return Promise.resolve(false); return window.showSaveFilePicker({ suggestedName: filename, types: [{ description: '文本文件', accept: { 'text/plain': ['.txt'] } }] }).then(handle => handle.createWritable().then(w => w.write(text).then(() => w.close()).then(() => true) ) ).catch(() => false); } function tryAnchor(filename, text) { let url = ''; try { url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); } catch (e) { return false; } if (!url || !/^blob:/i.test(url)) return false; const a = document.createElement('a'); a.href = url; a.download = filename; a.rel = 'noopener'; a.style.display = 'none'; // v3.4:全屏时只有全屏元素子树会被渲染,锚点挂进全屏宿主才点得动 const host = (typeof probeHost === 'function' && probeHost()) || document.documentElement || document.body; host.appendChild(a); let resolved = ''; try { resolved = a.href; } catch (e) {} if (!/^blob:/i.test(resolved)) { try { host.removeChild(a); } catch (e) {} try { URL.revokeObjectURL(url); } catch (e) {} return false; } a.dispatchEvent(new MouseEvent('click', { bubbles: false, cancelable: true, view: window })); setTimeout(() => { try { host.removeChild(a); } catch (e) {} try { URL.revokeObjectURL(url); } catch (e) {} }, 2000); return true; } function tryNewWindow(filename, text) { try { const w = window.open('', '_blank'); if (!w) return false; const esc = text.replace(/&/g, '&').replace(//g, '>'); w.document.write( '' + filename + '' + '
' + esc + '
' ); w.document.close(); return true; } catch (e) { return false; } } // ================= UI ================= let panel, output, btn; /* ===== v3.4:全屏态支持 ===== 原生全屏(requestFullscreen)只渲染「全屏元素及其后代」, 挂在 body 上的探针会被浏览器整块抹掉 —— 这就是"全屏时探针消失"的原因。 唯一解是把探针 UI 挪进 document.fullscreenElement 里, 并在 fullscreenchange 时来回搬家(退出全屏再搬回 body)。 注意:appendChild 是"移动",元素上的监听、checkbox 状态、 textarea 内容全部保留,不会重建、不会丢扫描结果。 */ function probeFsEl() { return document.fullscreenElement || document.webkitFullscreenElement || null; } function probeHost() { return probeFsEl() || document.body; } function injectProbeCss() { if (document.getElementById('__probe_css')) return; const st = document.createElement('style'); st.id = '__probe_css'; // fixed 定位保证 UI 相对视口(全屏时即整块屏幕),不会被全屏容器的 // overflow:hidden 裁掉;z-index 顶到最高,压过站内任何浮层。 st.textContent = '#__probe_btn{position:fixed !important;z-index:2147483646 !important;}' + '#__probe_panel{position:fixed !important;z-index:2147483647 !important;}'; (document.head || document.documentElement).appendChild(st); } function relocateUI() { const host = probeHost(); if (!host) return; if (btn && btn.parentNode !== host) host.appendChild(btn); if (panel && panel.parentNode !== host) host.appendChild(panel); updateFsBadge(); } function updateFsBadge() { const tag = document.getElementById('__probe_fs'); if (!tag) return; const fs = probeFsEl(); if (fs) { tag.textContent = '全屏态 · UI 已移入 ' + (fs.id ? '#' + fs.id : '<' + fs.tagName.toLowerCase() + '>'); tag.style.color = '#4ade80'; } else { tag.textContent = '普通态'; tag.style.color = '#888'; } } function buildButton() { if (document.getElementById('__probe_btn')) return; btn = document.createElement('div'); btn.id = '__probe_btn'; btn.textContent = '🔍'; btn.style.cssText = [ 'position:fixed', 'right:14px', 'bottom:90px', 'width:54px', 'height:54px', 'border-radius:50%', 'background:#fb7299', 'color:#fff', 'font-size:26px', 'line-height:54px', 'text-align:center', 'z-index:2147483646', 'box-shadow:0 4px 14px rgba(0,0,0,.35)', 'cursor:pointer', 'user-select:none', '-webkit-user-select:none', 'touch-action:manipulation' ].join(';'); probeHost().appendChild(btn); const open = e => { e.stopPropagation(); e.preventDefault(); openPanel(); }; btn.addEventListener('click', open, true); btn.addEventListener('touchend', open, true); } function buildPanel() { if (document.getElementById('__probe_panel')) return; panel = document.createElement('div'); panel.id = '__probe_panel'; panel.style.cssText = [ 'position:fixed', 'left:0', 'right:0', 'top:0', 'bottom:0', 'background:#1a1a1f', 'color:#fff', 'z-index:2147483647', 'display:none', 'flex-direction:column', 'font-family:ui-monospace,Menlo,Consolas,monospace', 'font-size:13px' ].join(';'); panel.innerHTML = '' + '
' + '探测 v3.5' + '' + '' + '' + '' + '' + '' + '' + '' + '
' + '
' + '' + '' + '' + '' + '' + '' + '' + '
' + ''; probeHost().appendChild(panel); output = panel.querySelector('#__probe_output'); panel.querySelector('#__probe_close').addEventListener('click', closePanel); panel.querySelector('#__probe_refresh').addEventListener('click', refresh); panel.querySelector('#__probe_diag').addEventListener('click', () => { output.value = diagTop(); const c = panel.querySelector('#__probe_count'); c.textContent = '顶部诊断已生成,请复制/存txt'; c.style.color = '#fbbf24'; }); panel.querySelector('#__probe_copy').addEventListener('click', () => { copyText(output.value, ok => { const c = panel.querySelector('#__probe_count'); const old = c.textContent; c.textContent = ok ? '已复制 ✓' : '复制失败,请长按选择'; c.style.color = ok ? '#4ade80' : '#f87171'; setTimeout(() => { c.textContent = old; c.style.color = '#888'; }, 1500); }); }); panel.querySelector('#__probe_save').addEventListener('click', () => { if (!output.value || output.value === '扫描中…') return; const c = panel.querySelector('#__probe_count'); const old = c.textContent; c.textContent = '保存中…'; c.style.color = '#aaa'; // v3.4:全屏状态下浏览器的下载提示/保存对话框画不出来, // 先记住这个状态,好在结果文案里提醒"退出全屏后可见" const wasFs = !!probeFsEl(); saveAsTxt('probe_' + timeStamp() + '.txt', output.value, mode => { const ok2 = (mode === 'picker' || mode === 'anchor'); c.textContent = ok2 ? (wasFs ? '已触发下载,退出全屏可见' : '已存 txt ↓') : (mode === 'window' ? '已开新窗口,请另存为' : '已复制到剪贴板'); c.style.color = ok2 ? '#22c55e' : '#fbbf24'; setTimeout(() => { c.textContent = old; c.style.color = '#888'; }, 2500); }); }); ['__probe_hidden','__probe_big','__probe_shadow','__probe_rect','__probe_css','__probe_top','__probe_fsroot'] .forEach(id => panel.querySelector('#' + id).addEventListener('change', refresh)); } function refresh() { const includeHidden = panel.querySelector('#__probe_hidden').checked; const onlyBig = panel.querySelector('#__probe_big').checked; const includeShadow = panel.querySelector('#__probe_shadow').checked; const withRect = panel.querySelector('#__probe_rect').checked; const withCss = panel.querySelector('#__probe_css').checked; const onlyTop = panel.querySelector('#__probe_top').checked; // v3.4:只扫全屏根 —— 全屏时页面其它节点其实都没渲染, // 勾上它就只列 document.fullscreenElement 子树,输出短、定位快 const fsRoot = panel.querySelector('#__probe_fsroot').checked; const root = fsRoot ? (probeFsEl() || document.body) : null; updateFsBadge(); output.value = '扫描中…'; setTimeout(() => { const text = scanPage({ includeHidden, includeShadow, withRect, withCss, onlyTop, root, minW: onlyBig ? 20 : 0, minH: onlyBig ? 20 : 0 }); output.value = text; const count = (text.match(/^\[\d+\]/gm) || []).length; panel.querySelector('#__probe_count').textContent = count + ' 个元素'; }, 30); } function openPanel() { panel.style.display = 'flex'; refresh(); } function closePanel() { panel.style.display = 'none'; } // ================= 初始化 ================= let fsBound = false; function bindFsWatch() { if (fsBound) return; fsBound = true; // 进出全屏都要把 UI 搬到新的宿主里;站内重排会晚几拍,多搬几次更稳 ['fullscreenchange', 'webkitfullscreenchange', 'MSFullscreenChange'].forEach(function (ev) { document.addEventListener(ev, function () { [0, 60, 200, 600].forEach(function (ms) { setTimeout(relocateUI, ms); }); }); }); // 站内也可能自己改 requestFullscreen 的目标,兜底:定期确认挂点正确 setInterval(function () { const host = probeHost(); if (btn && btn.parentNode && btn.parentNode !== host) relocateUI(); }, 1500); } function init() { if (!document.body) { setTimeout(init, 100); return; } injectProbeCss(); buildButton(); buildPanel(); relocateUI(); bindFsWatch(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } window.addEventListener('load', init); let lastCheck = 0; if (document.body) { new MutationObserver(() => { const now = Date.now(); if (now - lastCheck < 800) return; lastCheck = now; if (document.body && !document.getElementById('__probe_btn')) init(); }).observe(document.body, { childList: true }); } })();