为什么我在Zoom网页复制不全?原来是被“虚拟滚动”骗了! 💭 故事起因:一次复制失败的奇遇 主包读这个海硕一直有着录课然后用转文字内容进行总结和复习的习惯,在最近的dl课上忘记了自己录,只能靠着zoom的recording转录文字进行总结了。于是我尝试在zoom上把整份文字复制下来,但发现了一个神奇的事情——CA操作后,肉眼看到确实全蓝选中 ,可粘贴后——只剩视口展示的几行?!
我第一反应:“这肯定是zoom处理了复制事件吧?”但是转念一想,如果想要限制我们复制,为啥不直接像一些网站直接禁止复制事件?于是我进行了验证,打开控制台加监听:
document .addEventListener ('copy' , () => console .log ('copy event triggered' ))
结果果然复制事件照常触发,没有被拦截。
那……问题来了,既然复制事件没被拦截,那剩下的文字去哪了?
作为一位前端开发者,这样的问题当然值得去思考和解决
🕵️♀️ 真相:不是没复制,而是根本没“存在” 经过一些探索和思考后,我通过开发者工具往DOM里一看,发现 Zoom 的这块“文字记录面板”用的是一个叫vue-recycle-scroller 的组件。
然后我突然明白了——这其实是前端开发里的老朋友:
虚拟滚动(Virtual Scrolling) + 惰性渲染(Lazy Rendering)
🧩 那这俩是啥?为什么要用 🚀 虚拟滚动:只渲染“正在看”的部分 想象一下——如果一个会议转录有 几千行 ,浏览器一次性渲染所有内容,不仅内存爆炸,滚动还会卡成 PPT。
于是前端工程师用了一个聪明的办法:
“只渲染用户眼前看到的部分,别的先不画。”
这就是「虚拟滚动」。
当你往下滚时,前面的内容会被卸载(从 DOM 里删掉),后面的再被动态创建并显示。它就像电影院的卷轴,只露出一小段影片,其他还在卷起来的胶片里。
优点:
•性能大幅提升;
•页面加载更快、内存占用更低。
副作用:
•复制全选时只能复制到当前“存在”的那几条 ;
•自动化脚本或插件也只能看到可视范围的数据。
🌙 惰性渲染:能不画的先不画 惰性渲染(Lazy Rendering)其实是“按需显示”的理念。
比如打开页面时,只先加载首屏内容 ;
等你滚动、点击或交互时,再动态加载其他模块 。就像视频网站,先播前几秒,后面的边看边下。
优点:
•提升首屏加载速度;
•节约带宽和系统资源。
副作用:
•如果脚本要一次性抓取全部内容,就得想办法“唤醒”那些未渲染的部分。
🧠 那我怎么解决的? 理解了这两个机制后,我就知道:问题不在“复制”,而在“还没渲染”。
所以我写了一个小脚本,让浏览器自动滚动到底部 ,
边滚边采集当前加载的文字段落,最后拼成一份完整文本。
脚本主要思路:
1️⃣ 配置好开始和结束锚点(只取我想要的区间);
2️⃣ 模拟滚动加载(让虚拟列表把内容都渲染出来);
3️⃣ 清洗文本、按句子切分;
4️⃣ 用相似度算法去重(毕竟Zoom转录有时会重复一句话两次)。
最后一键导出,完整无缺,干干净净。
💬 最后想说 前端优化方案都在性能 和体验 之间找平衡。虚拟滚动、惰性渲染帮我们更快地看网页,但也意味着:有时候你看到的“全部”,其实只是“正在被渲染的那一小部分”。
所以下次再遇到“复制不全”的网页,不妨想想:
——是不是该滚一滚,看一看?
脚本 (async () => { const START_ANCHOR = "Hello, everybody. So welcome back to our course." ; const END_ANCHOR = "Oh, thank you. Thank you." ; const CASE_INSENSITIVE = true ; const SIM_THRESHOLD = 0.90 ; const WINDOW_BACK = 5 ; const EXPORT_AS = "download" ; let scroller = (typeof temp1 !== 'undefined' && temp1) || document .querySelector ('.vue-recycle-scroller' ) || document .querySelector ('[class*="recycle-scroller"]' ) || document .querySelector ('[class*="virtual"], [data-virtual]' ); if (!scroller) throw new Error ('未找到滚动容器:请先在 Elements 选中滚动区域并“Store as global variable”为 temp1。' ); const sleep = ms => new Promise (r => setTimeout (r, ms)); const seen = new Map (); function harvest ( ) { const items = scroller.querySelectorAll ( '.transcript-list-item, .vue-recycle-scroller__item-view, [data-index]' ); items.forEach (el => { const idx = el.getAttribute ('data-index' ) || el.id || (el.innerText || '' ).slice (0 , 80 ); const txtEl = el.querySelector ('.text' ) || el.querySelector ('[class*=text]' ) || el; let text = (txtEl?.innerText || '' ).trim (); if (!text) return ; const tEl = el.querySelector ('.time' ) || el.querySelector ('[class*=time], [data-role*=time]' ); const time = (tEl?.innerText || '' ).trim ().replace (/\s+/g , ' ' ); const withTime = time ? `${time} ${text} ` : text; if (!seen.has (idx)) seen.set (idx, { withTime, text }); }); } scroller.scrollTop = 0 ; await sleep (200 ); harvest (); let lastTop = -1 ; while (true ) { scroller.scrollTop += Math .max (500 , scroller.clientHeight * 0.9 ); await sleep (150 ); harvest (); const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2 ; const stuck = scroller.scrollTop === lastTop; if (atBottom || stuck) break ; lastTop = scroller.scrollTop ; } let entries = [...seen.entries ()]; const numeric = entries.every (([k] ) => !isNaN (+k)); if (numeric) entries.sort ((a, b ) => (+a[0 ]) - (+b[0 ])); const rows = entries.map (([, v] ) => v); const norm = s => CASE_INSENSITIVE ? s.toLowerCase () : s; const findIdx = (arr, needle, fromEnd = false ) => { if (!needle) return fromEnd ? arr.length - 1 : 0 ; const tgt = norm (needle); if (fromEnd) { for (let i = arr.length - 1 ; i >= 0 ; i--) { const a = norm (arr[i].text ), b = norm (arr[i].withTime ); if (a.includes (tgt) || b.includes (tgt)) return i; } return arr.length - 1 ; } else { for (let i = 0 ; i < arr.length ; i++) { const a = norm (arr[i].text ), b = norm (arr[i].withTime ); if (a.includes (tgt) || b.includes (tgt)) return i; } return 0 ; } }; const sIdx = findIdx (rows, START_ANCHOR , false ); const eIdx = findIdx (rows, END_ANCHOR , true ); const slice = rows.slice (Math .min (sIdx, rows.length - 1 ), Math .min (eIdx + 1 , rows.length )); const normalizeLine = (s ) => { return s .replace (/^\s*\d{1,2}:\d{2}:\d{2}\s*/ , '' ) .replace (/[.…]+/g , ' … ' ) .replace (/\s+/g , ' ' ) .replace (/([?!,;:])(?=\S)/g , '$1 ' ) .replace (/[“”]/g , '"' ).replace (/[‘’]/g , "'" ) .replace (/\b(\w+)\s+(?:…\s+)?\1\b/gi , '$1' ) .trim (); }; const fullText = slice.map (r => normalizeLine (r.text )).join (' ' ); const sentences = fullText .split (/(?<=[.!?])\s+(?=[A-Z0-9"'])|(?:\s+…\s+)/ ) .map (s => normalizeLine (s)) .filter (s => s && /[a-zA-Z]/ .test (s)); const toTokens = (s ) => s .toLowerCase () .replace (/[^a-z0-9'\s]/g , ' ' ) .replace (/\s+/g , ' ' ) .trim () .split (' ' ) .filter (Boolean ); const jaccard = (a, b ) => { const A = new Set (a), B = new Set (b); const inter = [...A].filter (x => B.has (x)).length ; const uni = new Set ([...A, ...B]).size || 1 ; return inter / uni; }; const dedup = []; const tokenCache = []; for (let i = 0 ; i < sentences.length ; i++) { const s = sentences[i]; if (!s) continue ; const tok = toTokens (s); if (!tok.length ) continue ; if (dedup.length && s === dedup[dedup.length - 1 ]) continue ; let dup = false ; for (let k = 1 ; k <= Math .min (WINDOW_BACK , tokenCache.length ); k++) { const prevTok = tokenCache[tokenCache.length - k]; if (jaccard (tok, prevTok) >= SIM_THRESHOLD ) { dup = true ; break ; } } if (dup) continue ; dedup.push (s); tokenCache.push (tok); } const outputText = dedup.join ('\n' ); if (EXPORT_AS === 'clipboard' && navigator.clipboard ) { await navigator.clipboard .writeText (outputText); console .log (`✅ 已复制到剪贴板:${dedup.length} 句` ); } else { const blob = new Blob ([outputText], { type : 'text/plain;charset=utf-8' }); const a = document .createElement ('a' ); a.href = URL .createObjectURL (blob); a.download = 'zoom_transcript_clean.txt' ; document .body .appendChild (a); a.click (); URL .revokeObjectURL (a.href ); a.remove (); console .log (`✅ 已下载:${dedup.length} 句` ); } })();