// ==UserScript== // @name Host Extractor UI (可视化域名抓取) // @namespace https://github.com/zsyo/cf-zt-split-sync // @version 1.1.0 // @description 点击按钮展开深色半透明控制台,支持 WebSocket/HTTPS 请求域名去重提取;悬浮按钮支持自由拖拽、磁吸贴边、悬停半隐藏与跨页面位置记忆,采用 Hits-Box 防抖设计。 // @author Zephyr // @license MIT // @match *://*/* // @grant GM_setClipboard // @grant GM_notification // @grant GM_getValue // @grant GM_setValue // @run-at document-start // ==/UserScript== (function() { 'use strict'; if (window.self !== window.top) return; const DEBUG_MODE = false; const hijackedHosts = new Set(); // ================= 【核心拦截:WebSocket】 ================= const NativeWebSocket = window.WebSocket; window.WebSocket = function(url, protocols) { try { if (url) { const wsUrl = url.startsWith('ws') ? url : new URL(url, window.location.href).href; const cleanUrl = wsUrl.replace(/^ws/, 'http'); const urlObj = new URL(cleanUrl); if (urlObj.host) hijackedHosts.add(urlObj.host); } } catch (e) { if (DEBUG_MODE) console.warn(`[HostExtractor] 拦截 WebSocket 域名失败:`, url, e); } return protocols ? new NativeWebSocket(url, protocols) : new NativeWebSocket(url); }; window.WebSocket.prototype = NativeWebSocket.prototype; // ================= 【核心逻辑:获取最终去重域名列表】 ================= function getSortedHosts() { const finalHosts = new Set(); finalHosts.add(window.location.host); hijackedHosts.forEach(h => finalHosts.add(h)); const resources = performance.getEntriesByType('resource'); resources.forEach(entry => { try { if (entry.name && entry.name.startsWith('http')) { const urlObj = new URL(entry.name); if (urlObj.host) finalHosts.add(urlObj.host); } } catch (e) { if (DEBUG_MODE) console.warn(`[HostExtractor] 解析 performance 资源域名失败:`, entry.name, e); } }); return Array.from(finalHosts).sort(); } // ================= 【UI 组件与交互逻辑】 ================= function initUI() { const BTN_SIZE = 40; const HIDE_OFFSET = 20; // 贴边收起距离 // 从持久化存储中读取位置 const savedSide = GM_getValue('he_dock_side', 'right'); const savedYRatio = GM_getValue('he_dock_y_ratio', 0.8); let currentDockSide = savedSide; let currentY = savedYRatio * window.innerHeight; // 1. 创建防抖外壳容器 (Hitbox Wrapper) - 负责响应鼠标 Hover 和定位 const btnWrapper = document.createElement('div'); btnWrapper.id = 'he-btn-wrapper'; Object.assign(btnWrapper.style, { position: 'fixed', zIndex: '999998', width: `${BTN_SIZE + HIDE_OFFSET + 8}px`, // 足够容纳展开和收起范围 height: `${BTN_SIZE}px`, top: `${currentY}px`, bottom: 'auto', right: currentDockSide === 'right' ? '0px' : 'auto', left: currentDockSide === 'left' ? '0px' : 'auto', pointerEvents: 'auto', display: 'flex', alignItems: 'center', justifyContent: currentDockSide === 'right' ? 'flex-end' : 'flex-start' }); // 2. 创建真实视觉按钮 (Inside Element) - 在 Wrapper 内部平滑位移 const triggerBtn = document.createElement('button'); triggerBtn.id = 'he-trigger-btn'; triggerBtn.innerText = '🌐'; triggerBtn.title = '长按按住可拖拽,点击展开域名面板'; Object.assign(triggerBtn.style, { width: `${BTN_SIZE}px`, height: `${BTN_SIZE}px`, borderRadius: '50%', backgroundColor: '#FFFFFF', color: '#18181B', border: '1px solid rgba(0, 0, 0, 0.12)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08)', cursor: 'grab', fontSize: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center', userSelect: 'none', touchAction: 'none', transition: 'transform 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28)', transform: `translateX(${currentDockSide === 'right' ? HIDE_OFFSET : -HIDE_OFFSET}px)` }); btnWrapper.appendChild(triggerBtn); let isDragging = false; let hasMoved = false; let startX = 0, startY = 0; let initialLeft = 0, initialTop = 0; // 更新位置及贴边状态 function updateBtnPosition(side, y, isHovered = false, saveToStorage = false) { currentDockSide = side; currentY = Math.max(10, Math.min(window.innerHeight - BTN_SIZE - 10, y)); btnWrapper.style.top = `${currentY}px`; btnWrapper.style.justifyContent = side === 'right' ? 'flex-end' : 'flex-start'; if (side === 'left') { btnWrapper.style.right = 'auto'; btnWrapper.style.left = '0px'; triggerBtn.style.transform = `translateX(${isHovered ? 8 : -HIDE_OFFSET}px)`; } else { btnWrapper.style.left = 'auto'; btnWrapper.style.right = '0px'; triggerBtn.style.transform = `translateX(${isHovered ? -8 : HIDE_OFFSET}px)`; } if (saveToStorage) { const yRatio = currentY / window.innerHeight; GM_setValue('he_dock_side', side); GM_setValue('he_dock_y_ratio', yRatio); } } // 3. 创建主展示面板 const panel = document.createElement('div'); panel.id = 'he-main-panel'; Object.assign(panel.style, { position: 'fixed', zIndex: '999999', width: '390px', maxHeight: '450px', borderRadius: '12px', backgroundColor: 'rgba(23, 23, 23, 0.82)', backdropFilter: 'blur(10px)', border: '1px solid rgba(255, 255, 255, 0.15)', boxShadow: '0 20px 25px -5px rgba(0,0,0,0.5), 0 10px 10px -5px rgba(0,0,0,0.4)', display: 'none', flexDirection: 'column', overflow: 'hidden', fontFamily: 'system-ui, -apple-system, sans-serif', color: '#E4E4E7' }); // 4. 构建 Header const header = document.createElement('div'); Object.assign(header.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid rgba(255,255,255,0.1)', fontWeight: '600', fontSize: '14px', letterSpacing: '0.5px' }); header.innerHTML = `🌐 Host 捕获控制台`; const headerBtnGroup = document.createElement('div'); headerBtnGroup.style.display = 'flex'; headerBtnGroup.style.gap = '8px'; const copyAllBtn = document.createElement('button'); copyAllBtn.innerText = '复制全部'; Object.assign(copyAllBtn.style, { backgroundColor: '#10B981', color: '#FFF', border: 'none', padding: '4px 8px', borderRadius: '4px', fontSize: '11px', cursor: 'pointer', fontWeight: '500' }); const closeBtn = document.createElement('button'); closeBtn.innerText = '✕'; Object.assign(closeBtn.style, { background: 'none', color: '#A1A1AA', border: 'none', fontSize: '14px', cursor: 'pointer', padding: '0 4px' }); headerBtnGroup.appendChild(copyAllBtn); headerBtnGroup.appendChild(closeBtn); header.appendChild(headerBtnGroup); panel.appendChild(header); // 5. 构建列表容器 const listContainer = document.createElement('div'); Object.assign(listContainer.style, { flex: '1', overflowY: 'auto', padding: '8px 12px', fontSize: '12px' }); panel.appendChild(listContainer); // ================= 【防抖动 Hover 监听】 ================= // 关键改动:向固定的 Wrapper 绑定 Hover 事件,绝对不会触发 Hover 循环死锁 btnWrapper.addEventListener('mouseenter', () => { if (!isDragging) updateBtnPosition(currentDockSide, currentY, true, false); }); btnWrapper.addEventListener('mouseleave', () => { if (!isDragging) updateBtnPosition(currentDockSide, currentY, false, false); }); // ================= 【拖拽交互】 ================= triggerBtn.addEventListener('pointerdown', (e) => { isDragging = true; hasMoved = false; startX = e.clientX; startY = e.clientY; const rect = btnWrapper.getBoundingClientRect(); initialLeft = rect.left; initialTop = rect.top; triggerBtn.style.cursor = 'grabbing'; triggerBtn.style.transition = 'none'; triggerBtn.setPointerCapture(e.pointerId); }); triggerBtn.addEventListener('pointermove', (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { hasMoved = true; } if (hasMoved) { const newLeft = initialLeft + dx; const newTop = initialTop + dy; btnWrapper.style.left = `${newLeft}px`; btnWrapper.style.top = `${newTop}px`; btnWrapper.style.right = 'auto'; triggerBtn.style.transform = 'none'; // 拖拽时消除偏移 } }); triggerBtn.addEventListener('pointerup', (e) => { if (!isDragging) return; isDragging = false; triggerBtn.style.cursor = 'grab'; triggerBtn.style.transition = 'transform 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28)'; triggerBtn.releasePointerCapture(e.pointerId); if (hasMoved) { const currentRect = btnWrapper.getBoundingClientRect(); const windowWidth = window.innerWidth; const centerX = currentRect.left + BTN_SIZE / 2; const targetSide = centerX < windowWidth / 2 ? 'left' : 'right'; updateBtnPosition(targetSide, currentRect.top, false, true); } else { togglePanel(); } }); function togglePanel() { if (panel.style.display === 'none') { renderList(); panel.style.display = 'flex'; const btnRect = btnWrapper.getBoundingClientRect(); let panelTop = Math.min(window.innerHeight - 460, Math.max(10, btnRect.top - 200)); panel.style.top = `${panelTop}px`; if (currentDockSide === 'left') { panel.style.left = `${btnRect.right + 12}px`; panel.style.right = 'auto'; } else { panel.style.right = `${(window.innerWidth - btnRect.left) + 12}px`; panel.style.left = 'auto'; } } else { panel.style.display = 'none'; } } closeBtn.addEventListener('click', (e) => { e.stopPropagation(); panel.style.display = 'none'; }); copyAllBtn.addEventListener('click', () => { const list = getSortedHosts(); if (list.length === 0) return; GM_setClipboard(list.join('\n'), 'text'); GM_notification({ text: `已成功复制全部 ${list.length} 个域名!`, title: 'Host Extractor', timeout: 2000 }); }); function renderList() { listContainer.innerHTML = ''; const hosts = getSortedHosts(); if (hosts.length === 0) { listContainer.innerHTML = `
未捕获到有效域名
`; return; } hosts.forEach((host, index) => { const item = document.createElement('div'); Object.assign(item.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 8px', borderRadius: '6px', marginBottom: '4px', transition: 'background-color 0.15s ease', backgroundColor: 'rgba(255,255,255,0.02)' }); item.addEventListener('mouseenter', () => { item.style.backgroundColor = 'rgba(255,255,255,0.08)'; }); item.addEventListener('mouseleave', () => { item.style.backgroundColor = 'rgba(255,255,255,0.02)'; }); const leftContainer = document.createElement('div'); Object.assign(leftContainer.style, { display: 'flex', alignItems: 'center', overflow: 'hidden', marginRight: '12px' }); const numSpan = document.createElement('span'); numSpan.innerText = String(index + 1).padStart(2, '0'); Object.assign(numSpan.style, { fontFamily: 'monospace', fontWeight: '700', color: '#71717A', marginRight: '10px', minWidth: '20px', textAlign: 'right', flexShrink: '0', fontSize: '11px' }); const textSpan = document.createElement('span'); textSpan.innerText = host; Object.assign(textSpan.style, { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', wordBreak: 'break-all', fontFamily: 'monospace', color: '#D4D4D8' }); leftContainer.appendChild(numSpan); leftContainer.appendChild(textSpan); const singleCopyBtn = document.createElement('button'); singleCopyBtn.innerText = '复制'; Object.assign(singleCopyBtn.style, { backgroundColor: 'rgba(255,255,255,0.1)', color: '#E4E4E7', border: 'none', padding: '2px 6px', borderRadius: '4px', fontSize: '10px', cursor: 'pointer', flexShrink: '0', transition: 'all 0.15s' }); singleCopyBtn.addEventListener('mouseenter', () => { singleCopyBtn.style.backgroundColor = '#10B981'; }); singleCopyBtn.addEventListener('mouseleave', () => { singleCopyBtn.style.backgroundColor = 'rgba(255,255,255,0.1)'; }); singleCopyBtn.addEventListener('click', (e) => { e.stopPropagation(); GM_setClipboard(host, 'text'); singleCopyBtn.innerText = '已复制'; singleCopyBtn.style.backgroundColor = '#059669'; setTimeout(() => { singleCopyBtn.innerText = '复制'; singleCopyBtn.style.backgroundColor = 'rgba(255,255,255,0.1)'; }, 1000); }); item.appendChild(leftContainer); item.appendChild(singleCopyBtn); listContainer.appendChild(item); }); } const interval = setInterval(() => { if (document.body) { clearInterval(interval); document.body.appendChild(btnWrapper); document.body.appendChild(panel); updateBtnPosition(currentDockSide, currentY, false, false); } }, 100); } initUI(); })();