// ==UserScript== // @name Trie文本替换与高亮 // @namespace http://tampermonkey.net/ // @version 25.0 // @description Aho-Corasick 高性能文本替换。纯离线 CSV 版。 // @author 老董的Gemini // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_openInTab // @run-at document-start // @downloadURL https://update.greasyfork.icu/scripts/557330/Trie%E6%96%87%E6%9C%AC%E6%9B%BF%E6%8D%A2%E4%B8%8E%E9%AB%98%E4%BA%AE.user.js // @updateURL https://update.greasyfork.icu/scripts/557330/Trie%E6%96%87%E6%9C%AC%E6%9B%BF%E6%8D%A2%E4%B8%8E%E9%AB%98%E4%BA%AE.meta.js // ==/UserScript== (function () { 'use strict'; // ================= 配置常量 ================= const STORAGE_KEY_REPLACE = 'ac_replace_rules'; const STORAGE_KEY_HIGHLIGHT = 'ac_highlight_rules'; const STORAGE_KEY_POS = 'ac_ball_position'; const CONFIG_HASH = '#ac-manager-dashboard'; const ATTR_REPLACE_DONE = 'data-ac-r'; // ================= 1. 配置页逻辑 ================= if (window.location.hash === CONFIG_HASH) { try { window.stop(); } catch (e) {} const renderConfig = () => { if (!document.documentElement) return setTimeout(renderConfig, 10); while (document.documentElement.attributes.length > 0) { document.documentElement.removeAttribute(document.documentElement.attributes[0].name); } document.documentElement.innerHTML = '规则管理'; document.documentElement.style.cssText = 'width:100%; height:100%; margin:0; padding:0; background:#eeeeee; display:block !important; visibility:visible !important; overflow:auto !important;'; document.body.style.cssText = 'margin:0; padding:0; width:100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #333;'; const div = document.createElement('div'); div.innerHTML = `
✅ 已保存
规则管理
1. 文本替换
2. 高亮显示
说明:支持 CSV 格式。
格式:A列=原词,B列=新词。
CSV 格式:A列=关键词,B列=颜色,C列=透明度。
关键词颜色透明度操作
`; document.body.appendChild(div); // --- 绑定逻辑 --- const $ = id => document.getElementById(id); const toast = (msg) => { const t=$('toast'); t.innerText=msg; t.classList.add('show'); setTimeout(()=>window.close(),600); }; const getTs = () => { const d=new Date(); return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}_${String(d.getHours()).padStart(2,'0')}${String(d.getMinutes()).padStart(2,'0')}`; }; const downloadCSV = (content, filename) => { const blob = new Blob(["\ufeff" + content], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement("a"); const url = URL.createObjectURL(blob); link.setAttribute("href", url); link.setAttribute("download", filename); document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const parseCSV = (text) => { const lines = text.split(/\r\n|\n/); const result = []; lines.forEach(line => { if(!line.trim()) return; const parts = line.split(','); const cleanParts = parts.map(p => p.trim().replace(/^"|"$/g, '')); if(cleanParts.length) result.push(cleanParts); }); return result; }; const parseTextarea = () => { const map = new Map(); $('txt-r').value.split('\n').forEach(l=>{ if(!l.trim()) return; const p = l.split(/\s*(?:=|->|=>|=|:|:)\s*/); if(p.length>=2) map.set(p[0].trim(), p[1].trim()); else if(l.includes(' ')) { const i=l.indexOf(' '); map.set(l.slice(0,i).trim(), l.slice(i).trim()); } }); return map; }; const mapToText = (map) => { let t=''; for(const [k,v] of map) t+=`${k} = ${v}\n`; return t; }; const getTableMap = () => { const map = new Map(); $('tbl-h').querySelectorAll('tbody tr').forEach(tr=>{ const i=tr.querySelectorAll('input'); const t=i[0].value.trim(); if(t) map.set(t, {text:t, color:i[1].value, opacity:parseFloat(i[2].value)}); }); return map; }; $('t1').onclick = () => { $('t1').className='ac-tab active'; $('t2').className='ac-tab'; $('v1').className='ac-view active'; $('v2').className='ac-view'; }; $('t2').onclick = () => { $('t1').className='ac-tab'; $('t2').className='ac-tab active'; $('v1').className='ac-view'; $('v2').className='ac-view active'; }; const loadR = () => { const d = GM_getValue(STORAGE_KEY_REPLACE, {}); $('txt-r').value = Object.entries(d).map(([k,v])=>`${k} = ${v}`).join('\n'); }; $('save-r').onclick = () => { GM_setValue(STORAGE_KEY_REPLACE, Object.fromEntries(parseTextarea())); toast('✅ 替换规则已保存'); }; $('clr-r').onclick = () => { if(confirm('清空?')) { GM_setValue(STORAGE_KEY_REPLACE,{}); loadR(); }}; $('exp-r').onclick = () => { const map = parseTextarea(); let csv = "原文本,替换文本\n"; for (const [k, v] of map) csv += `${k.includes(',')?`"${k}"`:k},${v.includes(',')?`"${v}"`:v}\n`; downloadCSV(csv, `ReplaceRules_${getTs()}.csv`); }; const addH = (x={text:'',color:'#FFFF00',opacity:1}) => { const tr = document.createElement('tr'); tr.innerHTML = `
${x.opacity}
`; tr.querySelector('input[type=range]').oninput = function(){this.nextElementSibling.innerText=this.value}; tr.querySelector('.btn-d').onclick = () => tr.remove(); $('tbl-h').querySelector('tbody').appendChild(tr); }; const loadH = () => { $('tbl-h').querySelector('tbody').innerHTML = ''; const d = GM_getValue(STORAGE_KEY_HIGHLIGHT, []); d.forEach(x => addH(x)); if(!d.length) addH(); }; const refreshH = (map) => { $('tbl-h').querySelector('tbody').innerHTML = ''; for(const v of map.values()) addH(v); if(map.size===0) addH(); }; $('add-h').onclick = () => addH(); $('save-h').onclick = () => { GM_setValue(STORAGE_KEY_HIGHLIGHT, Array.from(getTableMap().values())); toast('✅ 高亮规则已保存'); }; $('clr-h').onclick = () => { if(confirm('清空?')) { GM_setValue(STORAGE_KEY_HIGHLIGHT,[]); loadH(); }}; $('exp-h').onclick = () => { const map = getTableMap(); let csv = "关键词,颜色,透明度\n"; for (const v of map.values()) csv += `${v.text.includes(',')?`"${v.text}"`:v.text},${v.color},${v.opacity}\n`; downloadCSV(csv, `HighlightRules_${getTs()}.csv`); }; const f = $('file'); let mode = ''; $('imp-r').onclick = () => { mode='r'; f.click(); }; $('imp-h').onclick = () => { mode='h'; f.click(); }; f.onchange = e => { const file = e.target.files[0]; if(!file) return; const rdr = new FileReader(); rdr.onload = ev => { const rows = parseCSV(ev.target.result); let add=0, upd=0; if(mode==='r') { const map = parseTextarea(); rows.forEach((r, i) => { if(i===0 && (r[0]==='原文本' || r[0].includes('Key'))) return; if(r.length >= 1 && r[0]) { const k = r[0]; const v = r[1] || ''; if(map.has(k)) upd++; else add++; map.set(k, v); } }); $('txt-r').value = mapToText(map); } else { const map = getTableMap(); rows.forEach((r, i) => { if(i===0 && (r[0]==='关键词' || r[0].includes('Text'))) return; if(r.length >= 1 && r[0]) { const k = r[0]; if(map.has(k)) upd++; else add++; map.set(k, {text:k, color:r[1]||'#FFFF00', opacity:r[2]?parseFloat(r[2]):1}); } }); refreshH(map); } alert(`CSV 导入成功:新增 ${add}, 更新 ${upd}`); }; rdr.readAsText(file, 'UTF-8'); f.value=''; }; loadR(); loadH(); }; renderConfig(); return; } // ================= 2. 核心算法 ================= class ACMatcher { constructor() { this.root = { n: {}, f: null, o: [] }; } insert(w, d) { let p = this.root; for (let c of w) { if (!p.n[c]) p.n[c] = { n: {}, f: null, o: [] }; p = p.n[c]; } p.o.push({ w, d }); } build() { const q = []; for (const c in this.root.n) { this.root.n[c].f = this.root; q.push(this.root.n[c]); } while (q.length) { const u = q.shift(); for (const c in u.n) { const v = u.n[c]; let f = u.f; while (f && !f.n[c]) f = f.f; v.f = f ? f.n[c] : this.root; v.o = v.o.concat(v.f.o); q.push(v); } } } search(txt) { if (!txt) return []; let p = this.root, res = []; for (let i = 0; i < txt.length; i++) { const c = txt[i]; while (p !== this.root && !p.n[c]) p = p.f; p = p.n[c] || this.root; for (const o of p.o) res.push({ s: i - o.w.length + 1, e: i + 1, w: o.w, d: o.d }); } if(!res.length) return []; res.sort((a, b) => (a.s !== b.s) ? a.s - b.s : b.w.length - a.w.length); const fin = []; let last = -1; for (const m of res) { if (m.s >= last) { fin.push(m); last = m.e; } } return fin; } } function hexToRgba(hex, alpha) { if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) { let c = hex.substring(1).split(''); if (c.length === 3) c = [c[0], c[0], c[1], c[1], c[2], c[2]]; c = parseInt(c.join(''), 16); return `rgba(${(c>>16)&255},${(c>>8)&255},${c&255},${alpha})`; } return hex; } // ================= 3. 任务处理 ================= const nodeQueue = []; let isProcessing = false; let acR = null, acH = null; function processNode(node) { if (!node.nodeValue) return; const p = node.parentNode; if (!p || ['SCRIPT','STYLE','TEXTAREA','INPUT','SELECT','NOSCRIPT'].includes(p.tagName)) return; if (p.closest && p.closest('.ac-box')) return; if (p.tagName === 'MARK') return; const isReplacedAlready = p.hasAttribute(ATTR_REPLACE_DONE); let txt = node.nodeValue; if (acR && !isReplacedAlready) { const ms = acR.search(txt); if (ms.length) { const f = document.createDocumentFragment(); let l = 0; for (const m of ms) { if (m.s > l) f.appendChild(document.createTextNode(txt.slice(l, m.s))); const safeSpan = document.createElement('span'); safeSpan.textContent = m.d; safeSpan.setAttribute(ATTR_REPLACE_DONE, '1'); safeSpan.style.display = 'inline'; f.appendChild(safeSpan); l = m.e; } if (l < txt.length) f.appendChild(document.createTextNode(txt.slice(l))); if (node.parentNode) { node.parentNode.replaceChild(f, node); return; } } } if (acH) { const ms = acH.search(txt); if (ms.length) { const f = document.createDocumentFragment(); let l = 0; for (const m of ms) { if (m.s > l) f.appendChild(document.createTextNode(txt.slice(l, m.s))); const mk = document.createElement('mark'); mk.textContent = m.w; mk.style.cssText = `background-color:${hexToRgba(m.d.color, m.d.opacity)} !important; color:inherit; padding:0; margin:0; border-radius:2px;`; f.appendChild(mk); l = m.e; } if (l < txt.length) f.appendChild(document.createTextNode(txt.slice(l))); if (node.parentNode) { node.parentNode.replaceChild(f, node); } } } } function scheduleProcess() { if (isProcessing) return; isProcessing = true; const run = (deadline) => { while ((deadline.timeRemaining() > 1 || deadline.didTimeout) && nodeQueue.length > 0) { const node = nodeQueue.shift(); if (document.contains(node)) processNode(node); } if (nodeQueue.length > 0) (window.requestIdleCallback || setTimeout)(run, { timeout: 1000 }); else isProcessing = false; }; (window.requestIdleCallback || setTimeout)(run, { timeout: 1000 }); } function enqueue(node) { if (!node) return; nodeQueue.push(node); scheduleProcess(); } function initPage() { const rRules = GM_getValue(STORAGE_KEY_REPLACE, {}); const hRules = GM_getValue(STORAGE_KEY_HIGHLIGHT, []); if (Object.keys(rRules).length) { acR = new ACMatcher(); for(let k in rRules) if(k.trim()) acR.insert(k, rRules[k]); acR.build(); } if (hRules.length) { acH = new ACMatcher(); hRules.forEach(r => { if(r.text.trim()) acH.insert(r.text, r); }); acH.build(); } if (!acR && !acH) return; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); while(walker.nextNode()) enqueue(walker.currentNode); const observer = new MutationObserver(mutations => { mutations.forEach(m => { m.addedNodes.forEach(n => { if (n.nodeType === 3) enqueue(n); else if (n.nodeType === 1) { const w = document.createTreeWalker(n, NodeFilter.SHOW_TEXT, null, false); while(w.nextNode()) enqueue(w.currentNode); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); } // ================= 4. 悬浮球 (0.6x + 呼吸灯) ================= function createBall() { if(document.getElementById('gm-ac-ball')) return; const pos = GM_getValue(STORAGE_KEY_POS, { right: 30, bottom: 30 }); // 1. 注入 CSS 动画 (呼吸效果) const animStyle = document.createElement('style'); animStyle.textContent = ` @keyframes gm_pulse_cycle { 0%, 6.66%, 13.33%, 20%, 100% { background-color: #424242; box-shadow: 0 4px 10px rgba(0,0,0,0.2); transform: scale(1); border-color: #616161; } 3.33%, 10%, 16.66% { background-color: #1565C0; box-shadow: 0 0 10px rgba(21,101,192,0.8); transform: scale(1.1); border-color: #1565C0; } } #gm-ac-ball { animation: gm_pulse_cycle 15s infinite ease-in-out; } #gm-ac-ball:hover { animation: none !important; /* 鼠标放上去时停止呼吸,保持常亮 */ background-color: #1565C0 !important; transform: scale(1.1) !important; box-shadow: 0 0 15px rgba(21,101,192,0.6) !important; border-color: #1565C0 !important; } `; document.head.appendChild(animStyle); // 2. 创建球体 const b = document.createElement('div'); b.id = 'gm-ac-ball'; Object.assign(b.style, { position:'fixed', right:`${pos.right}px`, bottom:`${pos.bottom}px`, width:'30px', height:'30px', // 缩小到 0.6x (原50px -> 30px) borderRadius:'6px', // 缩小圆角 (原10px -> 6px) background:'#424242', color:'#f5f5f5', display:'flex', alignItems:'center', justifyContent:'center', fontSize:'10px', fontWeight:'bold', // 缩小字体 (原13px -> 10px) cursor:'pointer', zIndex:'2147483647', whiteSpace:'pre', lineHeight:'1.1', userSelect:'none', border: '1px solid #616161' }); b.innerText = "替换\n高亮"; // 拖拽逻辑 let d=false,sx,sy,sr,sb; b.onmousedown=e=>{d=true;sx=e.clientX;sy=e.clientY;const r=b.getBoundingClientRect();sr=window.innerWidth-r.right;sb=window.innerHeight-r.bottom;e.preventDefault();b.style.transition='none';}; window.addEventListener('mousemove',e=>{if(d){b.style.right=(sr+(sx-e.clientX))+'px';b.style.bottom=(sb+(sy-e.clientY))+'px';}}); window.addEventListener('mouseup',()=>{if(d){d=false;b.style.transition='';const r=b.getBoundingClientRect();GM_setValue(STORAGE_KEY_POS,{right:window.innerWidth-r.right,bottom:window.innerHeight-r.bottom});}}); let dp={}; b.addEventListener('mousedown',e=>dp={x:e.clientX,y:e.clientY}); b.addEventListener('click',e=>{ if(Math.abs(e.clientX-dp.x)<5 && Math.abs(e.clientY-dp.y)<5) GM_openInTab(window.location.href.split('#')[0] + CONFIG_HASH, {active:true}); }); document.body.appendChild(b); } if(window.location.hash !== CONFIG_HASH) { if(document.readyState==='loading') document.addEventListener('DOMContentLoaded', ()=>{ createBall(); initPage(); }); else { createBall(); initPage(); } } })();