☰
速记超人记事本:浏览器插件实现选中文本自动归档
2026/10/14 6:06:31 网站建设 项目流程

简介:速记超人记事本V1.0是一款面向学生、研究人员、程序员等高频网页文本处理人群的浏览器自动复制插件,可省去手动复制粘贴的繁琐操作,让资料收集与整理更专注高效。压缩包共8个文件,包含popup弹出界面、contentScript/background等后台脚本、manifest配置以及多尺寸图标,整体仅18KB,轻量精简,便于安装和二次修改。目前已有253人学习下载。该插件不仅支持自动捕获选中的网页内容,还能一键导出为txt文本或Excel表格;同时其代码结构清晰,由html、js、json组成,对想了解浏览器扩展开发流程的读者也是一份不错的入门范例,适合直接使用或参考改造。

1. 速记超人记事本:一款把「选中」变成「存档」的浏览器插件

用过浏览器自带收藏夹的人多半都有这种经历:看到一段有用的技术分析,先选中、再复制、切到文档里粘贴,来回几次后干脆放弃整理。速记超人记事本 V1.0 就是冲着这个场景去的——它把「选中文本 → 自动复制 → 按来源和标签归档」这条链路压缩成一次鼠标框选,附带时间戳、来源页面 URL 和当前页面标题,全程不需要切窗口,也不打断阅读节奏。做调研、写长文、扒接口文档时,它的价值会非常直观:你只管选,剩下的分类和检索由插件接管。

适合用它的人是内容消费量大、需要频繁摘录的群体:做竞品分析的产品经理、查文献的学生、写技术博客的工程师。插件本质上是「内容脚本 + 常驻记事本面板 + 本地存储」三件套,数据不经过任何远端服务器,断网也能用。V1.0 我只做核心闭环:选中自动复制、手动标记、来源记录、全文检索导出,尽量把复杂度压到最低,避免把记事本做成另一个 Notion。

我的整体设计思路:优先级最高的不是 UI 好看,而是「选中后要不要存」这个判断足够稳。太灵敏会存一堆垃圾,太迟钝又失去自动复制的意义。下面从事件监听开始,逐步拆清楚整个实现。

2. 从选中到自动复制:事件监听与文本质量过滤

2.1 用 mouseup + selectionchange 双事件捕获选中动作

选中文本这件事,浏览器原生事件里有两个候选:mouseup和selectionchange。V1.0 里我两个都用,但职责不一样——mouseup负责触发逻辑入口,selectionchange只用来兜底「选中之后还在继续拖拽扩展选区」的情况,避免每次选区变化都触发一次存储写入。

常见做法是只在 content script 里监听鼠标事件,但单纯监听mouseup会漏掉键盘操作(Shift+方向键)选中的场景,所以示例里把键盘事件也加进来,尽管使用频率不高。

// content.js const MIN_TEXT_LENGTH = 12; const MAX_TEXT_LENGTH = 8000; document.addEventListener('mouseup', handleAutoSelect, { passive: true }); document.addEventListener('keyup', (e) => { const selection = window.getSelection().toString().trim(); if (selection.length < MIN_TEXT_LENGTH) return; handleAutoSelect(e); }); async function handleAutoSelect() { const selection = window.getSelection(); if (!selection || selection.isCollapsed) return; let text = selection.toString().trim(); if (!text || text.length < MIN_TEXT_LENGTH) return; if (text.length > MAX_TEXT_LENGTH) text = text.slice(0, MAX_TEXT_LENGTH); // 避免同一段文本被反复触发 const cacheKey = `${location.href}:${text.slice(0, 32)}`; if (sessionStorage.getItem(cacheKey)) return; sessionStorage.setItem(cacheKey, String(Date.now())); await browser.storage.local.get({ clipboard: [] }).then(async (result) => { const start = Date.now(); result.clipboard.unshift({ id: `${start}-${Math.random().toString(16).slice(2, 8)}`, text, title: document.title, url: location.href, timestamp: new Date().toISOString(), tag: '未分类', archived: false }); await browser.storage.local.set({ clipboard: result.clipboard }); }); }

这段代码里比较关键的是缓存去重:用sessionStorage记住「当前页面 + 文本前 32 个字符」的组合,30 秒内重复选中同一段不会重复入库。MIN_TEXT_LENGTH和MAX_TEXT_LENGTH是文本质量过滤的两道闸门——太短的内容基本是导航链接或残句,太长的一篇网页全文直接存进来反而失去摘要价值。isCollapsed判断是防止用户只点了一下没拉出选区时误触发。

参数说明:12 个字符的阈值在中文环境适用,英文环境建议调到 30 个字符以上,否则高频的冠词、介词组合会大量入库;8000 字符上限不是硬性限制,而是面板渲染性能的红线,存一篇 2 万字的长文会让面板列表在低配机器上滚动卡顿。

2.2 自动复制为什么不等同于「写入剪贴板」

很多刚接触这个方向的人会把自动复制理解为选完就调document.execCommand('copy')或 Clipboard API 往系统剪贴板写。但实际操作里,自动写入剪贴板有两条硬限制:一是部分网页在非用户手势的异步回调里对剪贴板 API 的权限判断很严格,特别是 Manifest V3 插件在 service worker 里的上下文根本拿不到navigator.clipboard;二是用户选中文本是为了「之后再看」,直接覆盖系统剪贴板反而会破坏用户正在进行的复制操作。

V1.0 的做法是「先存进插件本地库,面板可手动点击复制」,系统剪贴板留在用户需要时再消费:

// popup.js 或面板内的复制按钮 async function copyEntry(entryId) { const { clipboard } = await browser.storage.local.get({ clipboard: [] }); const record = clipboard.find((item) => item.id === entryId); if (!record) return; const el = document.createElement('textarea'); el.value = `${record.text}\n—— ${record.title} (${record.url})`; document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el); }

旧 api 兼容性最好,虽然后台页面里已经被标记为 deprecated,但在 content script 用户手势的同步回调里仍然稳定。如果你希望彻底跟上新规范,可以在 manifest 里声明clipboardWrite权限后用navigator.clipboard.writeText(),但这样在扩展面板弹出状态下偶尔会触发权限提示,影响摘录节奏。

按我的经验,V1 阶段别急着追求实时剪切板同步,先保证「选中 → 保存」这个核心链路不丢数据,复制交互交给手动触发,忠实于标题的主记事本需求,自动复制本身指的就是自动捕获与存储,不是抢占系统剪贴板。

2.3 文本清洗:去掉无意义的换行和代码碎片

网页选区的文本和你在编辑器里看到的视觉效果不一样。getSelection().toString()会保留原始排版里的换行与空格,比如一个表格一行选下来,文本可能是「标题 内容 时间」这种空格拼接体。V1.0 有一个可选的轻量清洗流程,默认只做三件事:去掉首尾空白、连续空行压缩为一行、去掉不可见字符(零宽空格等)。

function normalizeText(raw) { return raw .replace(/[\u200B-\u200D\u2060\uFEFF]/g, '') .replace(/[ \t\u3000]{2,}/g, ' ') .replace(/\n{3,}/g, '\n\n') .trim(); }

这是在为后续全文检索做预处理。如果保留原始排版里混杂的各种不可见字符,搜索「某关键词」时可能因为字符串中间夹着零宽空格而匹配不到,这在线上环境非常隐蔽,是初版最容易栽的坑。此外,清洗只在入库时执行一次,不修改原文的段落结构性换行——单行变双行、双行之间的单换行保留,这样导出 Markdown 保留引用格式时基本不需二次处理。

3. 数据落盘:Manifest V3 下的 storage 选型与消息通信

3.1 为什么不用 localStorage:从插件隔离到权限边界

content script 在 Manifest V3 下是运行在页面隔离上下文里的,它本身不能直接读 chrome.storage,必须先走chrome.runtime.sendMessage把数据交给 background service worker,再由 worker 去写存储。而很多人会顺手写localStorage.setItem(),这其实有两层问题:第一,localStorage 只属于网页源(origin),插件卸载或浏览器清理站点数据时这些记录会清掉;第二,content script 里访问的是当前页面的 localStorage,用户换个 tab,数据就被隔离在另一个空间里了。

V1.0 的存储方案直接用chrome.storage.local,它的优势是生命周期跟浏览器 Profile 绑定,存再大也不会被单个网站的「清除站点数据」误伤。单条文本记录即使有 8000 字符,几千条的体量在 JSON 序列化后也只在几 MB 量级,不用上 IndexedDB——IndexedDB 面向更大体量和更强的索引需求,对 V2 版本做全文搜索时再迁移更合适。

落盘的核心消息协议:

// background.js (service worker) chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === 'SAVE_CLIP') { (async () => { const { clipboard } = await chrome.storage.local.get({ clipboard: [] }); // 写前做一次磁盘配额保护 if (clipboard.length >= 2000) { clipboard.pop(); } msg.payload.id = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; clipboard.unshift(msg.payload); await chrome.storage.local.set({ clipboard }); sendResponse({ ok: true, id: msg.payload.id }); })(); return true; // 保持通道打开让异步回复 } });

这里的 2000 条上限是保护机制,先把最重要的新记录放进队首,再从尾部丢弃最旧的记录;你可以在设置页把这个阈值调成 500——默认 2000 是为了避免用户在忘记整理时直接爆掉存储配额。注意return true非常关键,它告诉浏览器「我会异步回复,通道别关」。

3.2 面板自动刷新:靠 storage.onChanged 而不是轮询

手动打开面板去点「刷新」是原始方案。V1.0 里面板通过监听chrome.storage.onChanged拿到最新数组,只要内容脚本写入了一条新摘录,面板在毫秒级就能更新列表——这比 setInterval 定时轮询的体验好很多,也有助于解决「不断新旧混合读写」的并发问题。

// sidepanel.js 或 popup.js chrome.storage.onChanged.addListener((changes, areaName) => { if (areaName !== 'local' || !changes.clipboard) return; renderList(changes.clipboard.newValue); });

每次改动都会重渲染整个列表,几千条记录时列表渲染会有延迟,所以 V1.0 在渲染层做了分页:每页 50 条,滚动到底自动加载下一页。还有一点容易被忽略:面板本身和 content script 不共享同一个 JS 上下文,它们通过chrome.tabs.sendMessage(tabId, ...)通信,所以在面板里请求「当前选中摘要」时要带上 tabId,我第一次写的时候没传 tabId,结果面板一直拿不到当前页面的内容,排查半天才发现这个参数是必填的。

3.3 导出功能:JSON 备份与 Markdown 摘要双膝并行

忠实于记事本定位,V1.0 做了两条导出路径——JSON 全量备份和 Markdown 精选。JSON 备份面向数据迁移,Markdown 面向写作场景:把十几条相关的摘录按标题生成一个.md文件,粘贴到博客草稿或汇报文档里稍微调整就是初稿。导出这个动作的核心在于确保序列化后的结构足够稳定,不把零宽空格等不可见字符带到导出文件里。

function exportMarkdown(entries) { const lines = entries.map((entry) => { return [ `> ${entry.text.replace(/\n/g, '\n> ')}`, ``, `来源:${entry.title} | [原文](${entry.url}) 摘录于:${entry.timestamp}`, ``, ].join('\n'); }); return lines.join('\n---\n\n'); }

实现时注意Array.prototype.pop()要在unshift()之前处理,否则删的是队尾新记录。存储数据类型千万不能用 String 直接拼装,应该跨模块统一用Object,不然某条文本里出现特殊字符时导出文件会提前截断。

3.4 右键菜单与快捷键:降低手动录入摩擦

鼠标选中 + 右键菜单也可以成为核心入口,命名为「加入速记超人」。这个入口主要服务于「当前整段都要存但内容长度超了自动过滤阈值」的场景——自动过滤拒绝了 8000 字以上的全文,你仍然可以右键主动强制入库。配合 Chrome 的扩展快捷键功能,可以再绑定一个全局快捷键「立即打开面板」或「切换自动捕获开关」,更进一步降低打断感。这类次要入口不影响标题中自动复制的核心定义,但它能覆盖过滤条件导致的边缘需求。

4. 避坑指南:自动复制实现的五个经典翻车现场

4.1 在部分搜索页里选中的文本全部消失

现象:用户在某搜索引擎结果页里框选一段摘要,面板里什么都收不到,或者收到一条只有 URL 的空记录。反复选中都无效果。

原因:这些页面的结构里,页面正文外层有一个遮罩层覆盖,鼠标的mouseup事件没落在文本节点上,而是落在覆盖层上,导致getSelection().toString()返回空字符串。还有一种情况是页面使用影子 DOM,普通getSelection()在 shadow root 里按下选中的文本不归主文档的 selection 范围管。

解决:内容脚本里监听事件不做过滤,但在handleAutoSelect里增加document.activeElement和document.elementsFromPoint(e.clientX, e.clientY)的链路判断,当目标元素落在这些特殊容器内时,改用读取activeElement.innerText并根据选区文字修正。实际开发里更快的办法是页面检测到空文本后,往面板推一条「无法自动读取」的占位记录,并附上来源 URL,待用户手动补充内容。

function canReadSelection() { const activeTag = document.activeElement?.tagName; if (activeTag === 'INPUT' || activeTag === 'TEXTAREA') return false; const sel = window.getSelection(); return sel && sel.rangeCount > 0 && !sel.isCollapsed; }

4.2 同一段落存了十几条重复记录

现象:用户在长文档里拖动鼠标框选一小段,松手后存储了一堆。过一分钟再看,同样一段内容出现了 3~5 条,时间戳相差几秒。

原因:mouseup的监听位置和触发时机不对。用户从段落中间开始拖拽,中途停顿一下再继续拖,鼠标事件在停顿期间被浏览器重复派发。每次派发都触发了一次完整的存储写入;而 sessionStorage 去重键基于「URL + 文本前 32 字符」,这个停顿时间间隔短于存储完成时间,第二次触发时sessionStorage.getItem()还没拿到值,所以没有被挡住。

解决:把去重判断从同步改成异步,等第一次写入完成后再允许同一文本重复入库;同时把去重键从「前 32 字符」扩展成「全文 hash」,可以用crypto.subtle.digest对原始 text 生成 SHA-256,需要Content Script里在self.crypto下计算。

async function computeTextHash(text) { const data = new TextEncoder().encode(text); const digest = await crypto.subtle.digest('SHA-256', data); return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join(''); }

这套逻辑下,即使同一段文本在三个 tab 里各出现一次,不同 url 的前缀也让去重键天然区分,同一 tab 内的重复拖拽只会保存一条记录。

4.3 页面使用 iframe 时自动捕获失效

现象:在含 iframe 的文档平台内选中一段内容,控制台里能看到 iframe 内部的内容,但面板存的是外层文档的标题和 URL,而且文本有时候能存到、有时候存不到。

原因:window.getSelection()返回的是顶层 window 的选区,主文档的 content script 在跨域 iframe 时无法直接拿到 iframe 内部的 selection。Chrome 的 content script 只注入匹配 URL 的顶层文档,除非在 manifest 里显式声明all_frames: true。

解决:manifest 里把 content script 的all_frames设为 true,同时把存储消息统一让 background 聚合。如果只在自己的站点上使用,还可以把match_about_blank加上,这样 sandbox 页面也能捕获,代价是每个 iframe 都会注入一份监听器,脚本开销略微增大。

{ "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle", "all_frames": true, "match_about_blank": true } ] }

注意设置后,同一页面可能从多个 frame 发来多条存储消息,负责去重的逻辑必须放在 background 服务线程里统一处理,只在 content script 里做「发送前检查」是挡不住 iframe 内重复消息的。

4.4 旧版浏览器里剪贴板写入白屏报错

现象:在部分企业环境浏览器内核版本较老的环境下,插件点「复制」按钮,控制台报document.execCommand('copy') is not a function,或者面板直接白屏。

原因:这些浏览器禁用了document.execCommand,它不是一个标准函数,属于浏览器实现的兼容层。V1.0 的按钮点击直接同步调用它,没有做环境兜底。

解决:对复制函数做能力检测,失败时降级到navigator.clipboard.writeText,再失败就在面板里手动弹出提示框让用户自己复制。

async function copyText(text) { if (document.execCommand) { const el = document.createElement('textarea'); el.value = text; document.body.appendChild(el); el.select(); const ok = document.execCommand('copy'); document.body.removeChild(el); if (ok) return true; } try { await navigator.clipboard.writeText(text); return true; } catch (e) { prompt('手动复制:', text); return true; } }

4.5 插件更新后存储的旧记录全部消失

现象:V1.0 发布了一个小版本后,有用户反馈历史记录消失。排查发现不是记录丢了,而是新版代码里存储 key 从clipboard改成了notes,旧数据躺在clipboard字段下没人读。

原因:版本迭代时数据字段命名不兼容,旧版本的数据没有做迁移。

解决:后台 service worker 启动时做一次数据迁移,探测到旧 key 存在而新 key 不存在时,直接赋值。这个功能做起来很快,但给「自动复制插件」这类工具类项目设了一条底线:所有数据字段在版本更新里只能追加,不能改名,否则老用户的数据就成了黑匣子。

chrome.runtime.onInstalled.addListener(async () => { const old = await chrome.storage.local.get('clipboard'); const cur = await chrome.storage.local.get('notes'); if (old.clipboard && !cur.notes) { await chrome.storage.local.set({ notes: old.clipboard }); } });

这份迁移代码属于 V1.0 发布前就该写好的部分,否则一次换字段名就能废掉全部历史记录。

5. 进阶调优:为速记超人加入标签、全文搜索与自动清理策略

5.1 标签体系:手动标记辅助分类,避免过度设计

标题定位在「记事本」,那么分类逻辑就必须轻。给每条摘录提供一个 tag 字段,手动编辑。面板列表每条记录右侧跟一个输入框,鼠标移入才显示,失焦保存。真实使用里,手动标记频率很低,真正高频的场景是「按来源域名过滤」而不是「按语义标签过滤」。所以 V1.0 的默认搜索框是「全字段关键词搜索」,综合匹配 title、url、text、tag 四项,命中一项就展示。

function searchEntries(entries, keyword) { const kw = keyword.trim().toLowerCase(); if (!kw) return entries; return entries.filter((item) => { return ( (item.text || '').toLowerCase().includes(kw) || (item.title || '').toLowerCase().includes(kw) || (item.url || '').toLowerCase().includes(kw) || (item.tag || '').toLowerCase().includes(kw) ); }); }

如果后续标签使用频率确实很高,可以在入库时做一次关键词自动打标,内预置 8 个高频技术词(如 React、Python、数据库、性能)做正则匹配。V1.0 不建议做自动标签模型,重且易错,手动标记反而是迭代最快的路径。

5.2 全文索引:在 storage 之上自己造一个轻量级倒排

当记录数超过 1000 条,每次搜索都遍历数组做 indexOf 还可以接受;但超过 3000 条之后,输入框每按一个字符就触发的即时搜索明显有延迟。这里不需要引入外部搜索库,直接在每次存储变更后维护一份索引映射:关键词 → 记录 id 列表。

function buildIndex(entries) { const index = {}; for (const entry of entries) { const tokens = new Set((entry.text + ' ' + entry.title).toLowerCase().match(/[a-zA-Z0-9\u4e00-\u9fa5]+/g)); for (const token of tokens) { if (!index[token]) index[token] = []; index[token].push(entry.id); } } return index; }

这个分词策略相当朴素,中文按单字切分,英文按词切分,直接查表。对 V1.0 来说够用,索引体积大约等于原文的 0.5 倍,存在storage.local里没问题。值得留意的是每次onChanged只更新增量部分,不要全量重建索引,否则存储频繁写入反而更卡。

5.3 自动过期与配额管理:让数据自动收敛

这一条其实解决的是「值不值得做」的核心疑问:一个记事本如果只进不出,半年后必然成为一个无法使用的垃圾堆。V1.0 增加一个可配置策略:默认自动清理 90 天前且已标记为 archived(归档)的记录;没有归档的摘录不清,避免重要的东西悄然丢失。

async function autoPrune(days = 90) { const { notes, archiveDays } = await chrome.storage.local.get({ notes: [], archiveDays: days }); const cutoff = Date.now() - archiveDays * 24 * 60 * 60 * 1000; const filtered = notes.filter((n) => !(n.archived && new Date(n.timestamp).getTime() < cutoff)); if (filtered.length !== notes.length) { await chrome.storage.local.set({ notes: filtered }); } }

清理策略在面板设置页可以调天数,设为 0 代表永久保留。实际经验里,很多人会高估自己的整理频率,所以默认 90 天是安全的,且只清理已归档的摘录,保留未归档内容是对「自动」信任上的一道防线。

5.4 自定义导出模板:从摘录到产出稿的一条龙

除了通用 Markdown,很多用户希望导出时带上自己的格式习惯。提供三个预设模板:原文摘录版(原文 + 链接 + 时间)、观点注释版(摘录下方留空白行便于写批注)、组合版(按标签分组输出)。模板实现不复杂,本质上是一个字符串替换函数,但在真实工作流里非常提升复现意愿。

function renderTemplate(entry, template) { return template .replaceAll('{{text}}', entry.text) .replaceAll('{{title}}', entry.title) .replaceAll('{{url}}', entry.url) .replaceAll('{{tag}}', entry.tag) .replaceAll('{{time}}', entry.timestamp); }

导出体验的关键还在于「复制到剪贴板」而不是直接下载文件,用户粘贴到自己熟悉的编辑器再调整,比先保存文件再导入少一个步骤。

6. 调试与验证:一行代码确认自动复制链路是否完整

插件开发最大的玄学在于「没报错但没生效」。调试 V1.0 时,我习惯在每个关键节点输出调试信息,开启插件自己的 DEBUG 开关后,在页面上以 toast 形式展示捕获的文本、长度、去重键,让整个链路可视化。

// 设置 debug 后选中文本会弹出浮层显示调试信息 function debugToast(text) { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:99999;background:#333;color:#fff;padding:8px 12px;border-radius:4px;font-size:12px;max-width:480px;\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; overlay.textContent = `autoCapture: ${text.slice(0, 80)}...`; document.body.appendChild(overlay); setTimeout(() => overlay.remove(), 3000); }

把 DEBUG 开关放到chrome.storage.sync里,用户或自己可以随时远程打开,不用等面板操作。实际测试里最快验证整个链路的方法:打开任意一篇长文页面,按下 F12 在 console 里执行document.querySelector('p').select(),再手动触发一次mouseup,如果调试浮层里出现了一段文本,说明事件监听正常。然后看 storage 里notes数组的长度变化,判断落盘是否完成。

最后分享一个血泪习惯:插件类项目一定把「存储读写失败的兜底 UI」和「重复触发去重」放在最前面做完,不要等功能全写完再补。否则一旦上线,用户的数据损坏是不可逆的,这就是那个让你半夜爬起来找问题的坑。V1.0 只是第一步,如果你也用它整理了一个月材料,翻回来看那些自动捕获的记录,会理解真正应该投入的,其实是持续收集和验证的习惯本身。希望这篇笔记帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询